diff --git a/src/__tests__/hatch-guard.test.ts b/src/__tests__/hatch-guard.test.ts new file mode 100644 index 0000000..376ca05 --- /dev/null +++ b/src/__tests__/hatch-guard.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomUUID } from 'crypto'; +import { initDb, db, repairDuplicateCompanions } from '../db/schema.js'; +import { + companionExists, + createCompanion, + rescueCompanion, + loadCompanion, +} from '../lib/companion.js'; + +// Fresh, isolated DB state before each test. Child tables are cleared before +// companions because foreign_keys is ON (initReasoningSchema enables it). +beforeEach(() => { + initDb(); + db.prepare('DELETE FROM xp_events').run(); + db.prepare('DELETE FROM memories').run(); + db.prepare('DELETE FROM sessions').run(); + db.prepare('DELETE FROM evolution_history').run(); + db.prepare('DELETE FROM companions').run(); +}); + +function countCompanions(): number { + return (db.prepare('SELECT count(*) AS n FROM companions').get() as any).n; +} + +function addXpEvent(companionId: string): void { + db.prepare( + "INSERT INTO xp_events (id, companion_id, event_type, xp_gained) VALUES (?, ?, 'observe', 5)" + ).run(randomUUID(), companionId); +} + +describe('repairDuplicateCompanions', () => { + it('is a no-op with zero companions', () => { + expect(repairDuplicateCompanions()).toBe(0); + expect(companionExists()).toBeNull(); + }); + + it('is a no-op with a single companion', () => { + const { id } = createCompanion({ userId: 'solo', name: 'Solo' }); + expect(repairDuplicateCompanions()).toBe(0); + expect(countCompanions()).toBe(1); + expect((companionExists() as any).id).toBe(id); + }); + + it('collapses several rows down to the highest-XP companion', () => { + const { id: a } = createCompanion({ userId: 'a', name: 'Ayy' }); + const { id: b } = createCompanion({ userId: 'b', name: 'Bee' }); + const { id: c } = createCompanion({ userId: 'c', name: 'Cee' }); + db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(10, a); + db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(500, b); // most progressed + db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(0, c); + + expect(repairDuplicateCompanions()).toBe(2); + expect(countCompanions()).toBe(1); + expect((db.prepare('SELECT id FROM companions').get() as any).id).toBe(b); + }); + + it('deletes orphaned child rows of removed companions but keeps the survivor\'s', () => { + const { id: keep } = createCompanion({ userId: 'keep', name: 'Keeper' }); + const { id: drop } = createCompanion({ userId: 'drop', name: 'Dropped' }); + db.prepare('UPDATE companions SET xp = ? WHERE id = ?').run(100, keep); + + addXpEvent(keep); + addXpEvent(drop); + addXpEvent(drop); + + repairDuplicateCompanions(); + + expect((db.prepare('SELECT count(*) AS n FROM xp_events WHERE companion_id = ?').get(drop) as any).n).toBe(0); + expect((db.prepare('SELECT count(*) AS n FROM xp_events WHERE companion_id = ?').get(keep) as any).n).toBe(1); + }); + + it('breaks XP ties toward the earliest-inserted row', () => { + const { id: first } = createCompanion({ userId: 'first', name: 'First' }); + createCompanion({ userId: 'second', name: 'Second' }); // both default xp = 0 + + repairDuplicateCompanions(); + + expect(countCompanions()).toBe(1); + expect((db.prepare('SELECT id FROM companions').get() as any).id).toBe(first); + }); + + it('runs automatically as part of initDb()', () => { + createCompanion({ userId: 'x1', name: 'One' }); + createCompanion({ userId: 'x2', name: 'Two' }); + expect(countCompanions()).toBe(2); + + initDb(); // migration pass repairs duplicates + + expect(countCompanions()).toBe(1); + }); +}); + +describe('rescue-then-hatch guard', () => { + it('a rescued buddy makes companionExists() truthy, so buddy_hatch refuses', () => { + const { id } = rescueCompanion({ name: 'Fernsquire', species: 'Shell Turtle' }); + + // The buddy_hatch handler returns early whenever companionExists() is truthy, + // so the rescued buddy is never overwritten by a follow-up hatch. + const existing = companionExists(); + expect(existing).not.toBeNull(); + expect((existing as any).id).toBe(id); + expect(loadCompanion(existing)!.name).toBe('Fernsquire'); + }); + + it('leaves the rescued buddy intact (row + data still present)', () => { + rescueCompanion({ name: 'Fernsquire', species: 'Shell Turtle', personality: 'a wise, unflappable turtle' }); + + const row = db.prepare('SELECT * FROM companions LIMIT 1').get() as any; + expect(countCompanions()).toBe(1); + expect(row.name).toBe('Fernsquire'); + expect(row.species).toBe('Shell Turtle'); + expect(row.personality_bio).toBe('a wise, unflappable turtle'); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index ce46804..5ebe27a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -104,4 +104,55 @@ export function initDb() { // Reasoning-layer migration (claims/edges tables + guard_mode column). initReasoningSchema(db); + + // Collapse any legacy duplicate companion rows down to a single canonical + // buddy. Runs last, after every table exists, so child-row cleanup is safe. + repairDuplicateCompanions(); +} + +/** + * Repair legacy databases that accumulated more than one companion row. + * + * The old buddy_hatch did a bare INSERT, so hatching repeatedly left several + * rows behind. Every read path uses `SELECT ... LIMIT 1` with no ORDER BY, so + * those extras are stale duplicates that can shadow the real buddy (deleting a + * single row — as an earlier fix attempt did — is not enough when three or more + * exist). This collapses them down to one. + * + * The survivor is the most-progressed companion (highest XP): because the buggy + * LIMIT-1 read funnelled all observe/pet XP into whichever single row it kept + * surfacing, that row is the buddy the user actually raised; the rest are + * level-1 duplicates. Ties resolve to the earliest row (natural rowid order) — + * the buddy that was surfaced first. + * + * Returns the number of duplicate rows removed. Idempotent: a no-op at 0 or 1. + */ +export function repairDuplicateCompanions(): number { + const rows = db.prepare('SELECT id, xp FROM companions').all() as Array<{ id: string; xp: number }>; + if (rows.length <= 1) return 0; + + let keep = rows[0]; + for (const r of rows) { + if ((r.xp || 0) > (keep.xp || 0)) keep = r; + } + const doomed = rows.filter(r => r.id !== keep.id).map(r => r.id); + + // One transaction so a crash mid-repair can never leave the DB half-collapsed. + // Legacy tables (sessions/xp_events/memories/evolution_history) have no + // ON DELETE CASCADE and foreign_keys is ON, so clear children before the + // parent row; the reasoning_* tables cascade via their own FK. + const purge = db.transaction((ids: string[]) => { + for (const id of ids) { + db.prepare('DELETE FROM sessions WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM evolution_history WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM xp_events WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM memories WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM reasoning_findings_log WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM reasoning_observe_seq WHERE companion_id = ?').run(id); + db.prepare('DELETE FROM companions WHERE id = ?').run(id); + } + }); + purge(doomed); + + return doomed.length; } diff --git a/src/server/index.ts b/src/server/index.ts index 2c2386d..7b8a78e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,7 +23,7 @@ import { randomUUID } from "crypto"; import { readFileSync, unlinkSync, mkdirSync } from "fs"; import { join, dirname } from "path"; import { homedir } from "os"; -import { loadCompanion, writeBuddyStatus, createCompanion } from "../lib/companion.js"; +import { loadCompanion, writeBuddyStatus, createCompanion, companionExists } from "../lib/companion.js"; import { renderCard, hatchAnimation } from "../lib/card.js"; import { captureSnapshot } from "../lib/snapshot.js"; import { BUDDY_STATUS_PATH } from "../lib/constants.js"; @@ -320,6 +320,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { name?: string; species?: string; user_id?: string; }; + // Never silently replace an existing companion. Onboarding may have rescued + // a buddy (the installer then tells the user to hatch); overwriting it here + // would destroy that buddy and its history. Releasing is an explicit, + // separate action — the user must run buddy_respawn first. + const existing = companionExists(); + if (existing) { + const current = loadCompanion(existing); + const who = current + ? `${current.name} the ${current.species} (Lv.${current.level})` + : 'a companion'; + return { + content: [{ + type: "text", + text: `You already have ${who}. Hatching would abandon them and erase their history.\n\n` + + `If you truly want a new buddy, run buddy_respawn first to release ${current?.name ?? 'the current one'}, then buddy_hatch again.`, + }], + }; + } + const { companion } = createCompanion({ userId: user_id, name: requestedName,