diff --git a/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts b/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts new file mode 100644 index 0000000..087e621 --- /dev/null +++ b/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Schema for the Abandoned Watchpost (Playable Slice 0.10 §9, §5). + * + * Two unrelated-looking things in one migration because they arrive with one + * slice: the world gate that hides the Ash Pit route until it is found, and + * the two combat event types the Raider Veteran's new mechanics emit. + * + * `character_location_discoveries` is player state and nothing else -- which + * location is gated at all is content, and lives on the connection + * (AGENTS.md §7). A connection carrying its own gate means a place can be + * reachable by one road and hidden behind another. + */ +export class CreateAbandonedWatchpost1798000000000 + implements MigrationInterface +{ + name = 'CreateAbandonedWatchpost1798000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "character_location_discoveries" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "location_id" uuid NOT NULL, + "discovered_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_location_discoveries" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_location_discoveries_character" + FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE, + CONSTRAINT "FK_character_location_discoveries_location" + FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")`, + ); + + await queryRunner.query( + `ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false`, + ); + + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_character_location_discoveries_pair"`, + ); + await queryRunner.query(`DROP TABLE "character_location_discoveries"`); + await queryRunner.query( + `ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"`, + ); + + // Postgres cannot drop an enum value, so the type is rebuilt -- the same + // tradeoff migration 1790 already makes. Fails if any row uses one of the + // new values, which is the expected shape of a dev rollback. + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`, + ); + await queryRunner.query(`DROP TYPE "combat_event_type_enum"`); + await queryRunner.query( + `CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED', 'COMBAT_WON', 'COMBAT_LOST')`, + ); + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`, + ); + } +} diff --git a/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts b/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts new file mode 100644 index 0000000..5e0ede9 --- /dev/null +++ b/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { CreateAbandonedWatchpost1798000000000 } from './1798000000000-CreateAbandonedWatchpost'; + +/** + * The migration writes multi-line SQL, so every assertion below reads it with + * runs of whitespace collapsed -- the same harness the other migration specs + * use, so reindenting a statement never breaks a test that still describes + * the right schema. + */ +function collapse(statements: string[]): string { + return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); +} + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new CreateAbandonedWatchpost1798000000000().up(queryRunner); + return collapse(query.mock.calls.map(([sql]) => sql as string)); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateAbandonedWatchpost1798000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); +} + +describe('CreateAbandonedWatchpost1798000000000', () => { + it('creates the discovery table', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TABLE "character_location_discoveries"'); + }); + + it('lets a character discover a location only once', async () => { + const joined = await runUp(); + + // The unique index, not a disabled button, is what makes a repeated + // investigation harmless (AGENTS.md §30). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")', + ); + }); + + it('cascades discoveries away with their character and location', async () => { + const joined = await runUp(); + + expect(joined).toContain( + 'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE', + ); + expect(joined).toContain( + 'FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE', + ); + }); + + it('adds an ungated-by-default discovery flag to connections', async () => { + const joined = await runUp(); + + // Default false: every route that exists today stays walkable. + expect(joined).toContain( + 'ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false', + ); + }); + + it('extends the combat event enum with the two new mechanics', async () => { + const joined = await runUp(); + + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + }); + + it('reverses the table and the column', async () => { + const joined = await runDown(); + + expect(joined).toContain('DROP TABLE "character_location_discoveries"'); + expect(joined).toContain( + 'ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"', + ); + }); +});