feat(api): add schema for watchpost discovery and guard events

This commit is contained in:
Bastian Wagner
2026-08-23 10:15:10 +02:00
parent e3afc13c52
commit dd73f708c7
2 changed files with 167 additions and 0 deletions

View File

@@ -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<void> {
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<void> {
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"`,
);
}
}

View File

@@ -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<string> {
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<string> {
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"',
);
});
});