feat(quests): add quest schema, entities and migration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* The quest system (Playable Slice 0.9 §10; NPC spec §14, §40).
|
||||
*
|
||||
* Four tables, split the way AGENTS.md §7 asks: `quest_definitions` and
|
||||
* `quest_objectives` are content, `npc_quest_assignments` is the content link
|
||||
* NPC spec §14 specifies, and `character_quests` is the only player state.
|
||||
*
|
||||
* There is deliberately no per-objective progress table. Slice 0.9 §11 asks
|
||||
* objectives to "derive progress from current owned quantity where
|
||||
* appropriate", and doing exactly that is what makes every softlock case in
|
||||
* §11 fall out for free: sell the pelts and the objective moves back, own some
|
||||
* before accepting and they already count.
|
||||
*/
|
||||
export class CreateQuestSystem1797000000000 implements MigrationInterface {
|
||||
name = 'CreateQuestSystem1797000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "quest_objective_type_enum" AS ENUM ('COLLECT_ITEM', 'TALK_TO_NPC')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "npc_quest_role_enum" AS ENUM ('OFFER', 'TURN_IN', 'PROGRESS')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "character_quest_status_enum" AS ENUM ('ACTIVE', 'COMPLETED')`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "quest_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"title" character varying(150) NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"reward_faction_key" character varying(100),
|
||||
"reward_reputation" integer NOT NULL DEFAULT 0,
|
||||
"reward_silver" integer NOT NULL DEFAULT 0,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_quest_definitions" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_quest_definitions_key" ON "quest_definitions" ("key")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "quest_objectives" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"quest_id" uuid NOT NULL,
|
||||
"key" character varying(100) NOT NULL,
|
||||
"order_index" integer NOT NULL,
|
||||
"type" "quest_objective_type_enum" NOT NULL,
|
||||
"target_key" character varying(100) NOT NULL,
|
||||
"required_quantity" integer NOT NULL DEFAULT 1,
|
||||
"description" character varying(255) NOT NULL,
|
||||
"npc_line" text,
|
||||
"hint_text" text,
|
||||
"advance_when_blocked" boolean NOT NULL DEFAULT false,
|
||||
"consume_on_complete" boolean NOT NULL DEFAULT false,
|
||||
"grants_loot_bag_key" character varying(100),
|
||||
"sets_flag_key" character varying(100),
|
||||
"sets_flag_npc_key" character varying(100),
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_quest_objectives" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "quest_objectives"
|
||||
ADD CONSTRAINT "FK_quest_objectives_quest"
|
||||
FOREIGN KEY ("quest_id")
|
||||
REFERENCES "quest_definitions"("id") ON DELETE CASCADE
|
||||
`);
|
||||
// A step asking for zero of something would be satisfied before the player
|
||||
// did anything, which is a content bug the database can simply refuse.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "quest_objectives"
|
||||
ADD CONSTRAINT "CHK_quest_objectives_required_quantity"
|
||||
CHECK ("required_quantity" >= 1)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_quest_objectives_quest_key" ON "quest_objectives" ("quest_id", "key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_quest_objectives_quest_order" ON "quest_objectives" ("quest_id", "order_index")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "npc_quest_assignments" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"npc_id" uuid NOT NULL,
|
||||
"quest_id" uuid NOT NULL,
|
||||
"role" "npc_quest_role_enum" NOT NULL,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_npc_quest_assignments" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "npc_quest_assignments"
|
||||
ADD CONSTRAINT "FK_npc_quest_assignments_npc"
|
||||
FOREIGN KEY ("npc_id")
|
||||
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "npc_quest_assignments"
|
||||
ADD CONSTRAINT "FK_npc_quest_assignments_quest"
|
||||
FOREIGN KEY ("quest_id")
|
||||
REFERENCES "quest_definitions"("id") ON DELETE CASCADE
|
||||
`);
|
||||
// The role is part of the key on purpose: NPC spec §14 wants one person to
|
||||
// be able to both offer and receive the same quest, which the warden does.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_npc_quest_assignments_npc_quest_role" ON "npc_quest_assignments" ("npc_id", "quest_id", "role")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_npc_quest_assignments_npc" ON "npc_quest_assignments" ("npc_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "character_quests" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"quest_id" uuid NOT NULL,
|
||||
"status" "character_quest_status_enum" NOT NULL,
|
||||
"current_objective_index" integer NOT NULL DEFAULT 0,
|
||||
"accepted_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"completed_at" TIMESTAMP WITH TIME ZONE,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_quests" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "character_quests"
|
||||
ADD CONSTRAINT "FK_character_quests_character"
|
||||
FOREIGN KEY ("character_id")
|
||||
REFERENCES "characters"("id") ON DELETE CASCADE
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "character_quests"
|
||||
ADD CONSTRAINT "FK_character_quests_quest"
|
||||
FOREIGN KEY ("quest_id")
|
||||
REFERENCES "quest_definitions"("id") ON DELETE RESTRICT
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "character_quests"
|
||||
ADD CONSTRAINT "CHK_character_quests_objective_index"
|
||||
CHECK ("current_objective_index" >= 0)
|
||||
`);
|
||||
// "Accepted only once" (slice §13) is guaranteed here, not by the UI
|
||||
// disabling a button (AGENTS.md §30).
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_character_quests_character_quest" ON "character_quests" ("character_id", "quest_id")`,
|
||||
);
|
||||
|
||||
// Slice 0.9 decision D5. `vertical-slice.seed.ts` handed the demo character
|
||||
// a free Basic Hide Bag as a stopgap, with a comment saying to remove it
|
||||
// "once 0.9 hands the Hide Bag over in the quest". This is that moment:
|
||||
// leaving the row in place would hide the default HIDE capacity of 1, and
|
||||
// that limit is the entire premise of the slice (§4). Scoped to the demo
|
||||
// character and that one bag definition; no other row is touched.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "character_loot_bags"
|
||||
WHERE "character_id" = '10000000-0000-4000-8000-000000000001'
|
||||
AND "loot_bag_definition_id" = 'a0000000-0000-4000-8000-000000000001'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// The deleted stopgap bag is deliberately not restored: a rollback cannot
|
||||
// tell it apart from a bag the player actually earned, and re-inserting a
|
||||
// bag somebody may have bought would be the worse mistake.
|
||||
await queryRunner.query(`DROP TABLE "character_quests"`);
|
||||
await queryRunner.query(`DROP TABLE "npc_quest_assignments"`);
|
||||
await queryRunner.query(`DROP TABLE "quest_objectives"`);
|
||||
await queryRunner.query(`DROP TABLE "quest_definitions"`);
|
||||
|
||||
await queryRunner.query(`DROP TYPE "character_quest_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "npc_quest_role_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "quest_objective_type_enum"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'reflect-metadata';
|
||||
import { QueryRunner } from 'typeorm';
|
||||
import { CreateQuestSystem1797000000000 } from './1797000000000-CreateQuestSystem';
|
||||
|
||||
/**
|
||||
* The migration writes multi-line SQL, so every assertion below reads it with
|
||||
* runs of whitespace collapsed. Otherwise a statement would have to be quoted
|
||||
* back with its exact indentation, and reindenting the migration would break
|
||||
* tests that still describe 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 CreateQuestSystem1797000000000().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 CreateQuestSystem1797000000000();
|
||||
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('CreateQuestSystem1797000000000', () => {
|
||||
it('creates the four quest tables', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain('CREATE TABLE "quest_definitions"');
|
||||
expect(joined).toContain('CREATE TABLE "quest_objectives"');
|
||||
expect(joined).toContain('CREATE TABLE "npc_quest_assignments"');
|
||||
expect(joined).toContain('CREATE TABLE "character_quests"');
|
||||
});
|
||||
|
||||
it('creates the three quest enum types', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain('CREATE TYPE "quest_objective_type_enum"');
|
||||
expect(joined).toContain('CREATE TYPE "npc_quest_role_enum"');
|
||||
expect(joined).toContain('CREATE TYPE "character_quest_status_enum"');
|
||||
});
|
||||
|
||||
it('makes a quest acceptable only once per character', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
// The database, not a disabled button, is what guarantees this
|
||||
// (AGENTS.md §30).
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_character_quests_character_quest" ON "character_quests" ("character_id", "quest_id")',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps objective keys and order unique within a quest', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_key" ON "quest_objectives" ("quest_id", "key")',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_order" ON "quest_objectives" ("quest_id", "order_index")',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets one quest use several NPCs in different roles', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
// The unique triple includes the role, so the same NPC can both offer and
|
||||
// receive a quest (NPC spec §14).
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_npc_quest_assignments_npc_quest_role" ON "npc_quest_assignments" ("npc_id", "quest_id", "role")',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores step effects as content columns', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain('"advance_when_blocked" boolean');
|
||||
expect(joined).toContain('"consume_on_complete" boolean');
|
||||
expect(joined).toContain('"grants_loot_bag_key" character varying');
|
||||
expect(joined).toContain('"sets_flag_key" character varying');
|
||||
expect(joined).toContain('"sets_flag_npc_key" character varying');
|
||||
expect(joined).toContain('"npc_line" text');
|
||||
expect(joined).toContain('"hint_text" text');
|
||||
});
|
||||
|
||||
it('rejects nonsensical quantities and indexes', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain('CHK_quest_objectives_required_quantity');
|
||||
expect(joined).toContain('CHK_character_quests_objective_index');
|
||||
});
|
||||
|
||||
it('cascades objectives and assignments but never deletes a quest in use', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
expect(joined).toContain('FK_quest_objectives_quest');
|
||||
expect(joined).toContain('FK_npc_quest_assignments_npc');
|
||||
expect(joined).toContain('FK_npc_quest_assignments_quest');
|
||||
expect(joined).toContain('FK_character_quests_character');
|
||||
// A quest a character is on must not vanish underneath them.
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("quest_id") REFERENCES "quest_definitions"("id") ON DELETE RESTRICT',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the demo character stopgap hide bag', async () => {
|
||||
const joined = await runUp();
|
||||
|
||||
// Slice 0.9 D5: the seed handed this bag over for free so the hunting loop
|
||||
// stayed playable between 0.8.5 and 0.9. Leaving it would hide the default
|
||||
// HIDE capacity of 1, which is the whole premise of this slice.
|
||||
expect(joined).toContain('DELETE FROM "character_loot_bags"');
|
||||
expect(joined).toContain('10000000-0000-4000-8000-000000000001');
|
||||
expect(joined).toContain('a0000000-0000-4000-8000-000000000001');
|
||||
});
|
||||
|
||||
it('drops every table and type it created on down', async () => {
|
||||
const joined = await runDown();
|
||||
|
||||
expect(joined).toContain('DROP TABLE "character_quests"');
|
||||
expect(joined).toContain('DROP TABLE "npc_quest_assignments"');
|
||||
expect(joined).toContain('DROP TABLE "quest_objectives"');
|
||||
expect(joined).toContain('DROP TABLE "quest_definitions"');
|
||||
expect(joined).toContain('DROP TYPE "character_quest_status_enum"');
|
||||
expect(joined).toContain('DROP TYPE "npc_quest_role_enum"');
|
||||
expect(joined).toContain('DROP TYPE "quest_objective_type_enum"');
|
||||
});
|
||||
|
||||
it('does not resurrect the deleted hide bag on down', async () => {
|
||||
const joined = await runDown();
|
||||
|
||||
// A rollback cannot tell the seeded stopgap from a bag the player earned,
|
||||
// so it restores neither.
|
||||
expect(joined).not.toContain('INSERT INTO "character_loot_bags"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user