feat(api): add schema for watchpost discovery and guard events
This commit is contained in:
@@ -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"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user