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