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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/api/src/quests/entities/character-quest.entity.ts
Normal file
77
apps/api/src/quests/entities/character-quest.entity.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { CharacterQuestStatus } from '../quest.types';
|
||||||
|
import { QuestDefinition } from './quest-definition.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one character stands with one quest (Slice 0.9 §10, §11).
|
||||||
|
*
|
||||||
|
* Player state, kept away from the quest content it points at (AGENTS.md §7).
|
||||||
|
* There is deliberately no per-objective progress table: a collect step's
|
||||||
|
* progress is read from what the character owns right now (spec §11), so
|
||||||
|
* selling the pelts moves the objective back on its own instead of leaving a
|
||||||
|
* stored counter lying about the inventory.
|
||||||
|
*
|
||||||
|
* `currentObjectiveIndex` is therefore a *floor*, not the answer. It only moves
|
||||||
|
* when a talk step is performed -- those are irreversible -- and the effective
|
||||||
|
* step is derived from it by `resolveCurrentObjectiveIndex`.
|
||||||
|
*
|
||||||
|
* The unique index is what makes "accepted only once" a database guarantee
|
||||||
|
* rather than a disabled button (AGENTS.md §30, spec §13).
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'character_quests' })
|
||||||
|
@Index('IDX_character_quests_character_quest', ['characterId', 'questId'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class CharacterQuest {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id', type: 'uuid' })
|
||||||
|
characterId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quest_id', type: 'uuid' })
|
||||||
|
questId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: CharacterQuestStatus,
|
||||||
|
enumName: 'character_quest_status_enum',
|
||||||
|
})
|
||||||
|
status!: CharacterQuestStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'current_objective_index', type: 'integer', default: 0 })
|
||||||
|
currentObjectiveIndex!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'accepted_at', type: 'timestamptz' })
|
||||||
|
acceptedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character!: Character;
|
||||||
|
|
||||||
|
// RESTRICT, not CASCADE: a quest a character is standing on must not vanish
|
||||||
|
// underneath them because a content row was deleted.
|
||||||
|
@ManyToOne(() => QuestDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'quest_id' })
|
||||||
|
quest!: QuestDefinition;
|
||||||
|
}
|
||||||
68
apps/api/src/quests/entities/npc-quest-assignment.entity.ts
Normal file
68
apps/api/src/quests/entities/npc-quest-assignment.entity.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||||
|
import { NpcQuestRole } from '../quest.types';
|
||||||
|
import { QuestDefinition } from './quest-definition.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which NPC does what for a quest (NPC spec §14).
|
||||||
|
*
|
||||||
|
* §14 rejects `npc.isQuestGiver = true` on purpose: a quest may be offered by
|
||||||
|
* one person, pushed forward by a second and handed in to a third. This chain
|
||||||
|
* uses that immediately -- the warden offers and receives, Borin sits in the
|
||||||
|
* middle -- which is why the unique index below includes `role`: the same NPC
|
||||||
|
* legitimately holds two of them.
|
||||||
|
*
|
||||||
|
* Slice 0.8's NPC spec §40 listed this table as its Abweichung 1, deferred
|
||||||
|
* because there was no `quest_definitions` to point at. There is now.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'npc_quest_assignments' })
|
||||||
|
@Index(
|
||||||
|
'IDX_npc_quest_assignments_npc_quest_role',
|
||||||
|
['npcId', 'questId', 'role'],
|
||||||
|
{ unique: true },
|
||||||
|
)
|
||||||
|
@Index('IDX_npc_quest_assignments_npc', ['npcId'])
|
||||||
|
export class NpcQuestAssignment {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'npc_id', type: 'uuid' })
|
||||||
|
npcId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quest_id', type: 'uuid' })
|
||||||
|
questId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'role',
|
||||||
|
type: 'enum',
|
||||||
|
enum: NpcQuestRole,
|
||||||
|
enumName: 'npc_quest_role_enum',
|
||||||
|
})
|
||||||
|
role!: NpcQuestRole;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'npc_id' })
|
||||||
|
npc!: NpcDefinition;
|
||||||
|
|
||||||
|
@ManyToOne(() => QuestDefinition, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'quest_id' })
|
||||||
|
quest!: QuestDefinition;
|
||||||
|
}
|
||||||
63
apps/api/src/quests/entities/quest-definition.entity.ts
Normal file
63
apps/api/src/quests/entities/quest-definition.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One quest, as content (Playable Slice 0.9 §10).
|
||||||
|
*
|
||||||
|
* Content, never player state: what a quest *is* lives here, where a character
|
||||||
|
* stands with it lives in `CharacterQuest` (AGENTS.md §7). The reward columns
|
||||||
|
* sit on the quest rather than on the final step because they are paid once,
|
||||||
|
* on completion, whichever NPC happens to receive it.
|
||||||
|
*
|
||||||
|
* `rewardSilver` is seeded at 0 and `renownMilestoneKey` is deliberately absent
|
||||||
|
* (Slice 0.9 decisions D2/D3): the bag is the reward, a Silver payout would
|
||||||
|
* undercut the merchant trade loop, and a renown milestone here would reach
|
||||||
|
* World Renown 3 and open the Bandit Blade that Slice 0.8.5 parked until 0.11.
|
||||||
|
* Both stay tunable as content instead of needing a code change.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'quest_definitions' })
|
||||||
|
@Index('IDX_quest_definitions_key', ['key'], { unique: true })
|
||||||
|
export class QuestDefinition {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
/** Stable business key used by seeds, conditions and tests (AGENTS.md §8). */
|
||||||
|
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'title', type: 'varchar', length: 150 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text' })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
/** Which faction the reputation reward is paid to. Null pays none. */
|
||||||
|
@Column({
|
||||||
|
name: 'reward_faction_key',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
rewardFactionKey!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reward_reputation', type: 'integer', default: 0 })
|
||||||
|
rewardReputation!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'reward_silver', type: 'integer', default: 0 })
|
||||||
|
rewardSilver!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
129
apps/api/src/quests/entities/quest-entities.metadata.spec.ts
Normal file
129
apps/api/src/quests/entities/quest-entities.metadata.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import {
|
||||||
|
CharacterQuestStatus,
|
||||||
|
NpcQuestRole,
|
||||||
|
QuestObjectiveType,
|
||||||
|
} from '../quest.types';
|
||||||
|
import { CharacterQuest } from './character-quest.entity';
|
||||||
|
import { NpcQuestAssignment } from './npc-quest-assignment.entity';
|
||||||
|
import { QuestDefinition } from './quest-definition.entity';
|
||||||
|
import { QuestObjective } from './quest-objective.entity';
|
||||||
|
|
||||||
|
function tableName(target: unknown): string | undefined {
|
||||||
|
return getMetadataArgsStorage().tables.find(
|
||||||
|
(table) => table.target === target,
|
||||||
|
)?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnFor(target: unknown, propertyName: string) {
|
||||||
|
return getMetadataArgsStorage().columns.find(
|
||||||
|
(column) => column.target === target && column.propertyName === propertyName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnNames(target: unknown): string[] {
|
||||||
|
return getMetadataArgsStorage()
|
||||||
|
.columns.filter((column) => column.target === target)
|
||||||
|
.map((column) => column.propertyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIndexFor(target: unknown, columns: string[]): boolean {
|
||||||
|
const index = getMetadataArgsStorage().indices.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === target &&
|
||||||
|
Array.isArray(candidate.columns) &&
|
||||||
|
candidate.columns.length === columns.length &&
|
||||||
|
columns.every((column) => candidate.columns?.includes(column)),
|
||||||
|
);
|
||||||
|
const meta = index as typeof index & {
|
||||||
|
options?: { unique?: boolean };
|
||||||
|
unique?: boolean;
|
||||||
|
};
|
||||||
|
return (meta?.options?.unique ?? meta?.unique) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Slice 0.9 quest entity metadata', () => {
|
||||||
|
it('maps every quest entity to the table the migration creates', () => {
|
||||||
|
expect(tableName(QuestDefinition)).toBe('quest_definitions');
|
||||||
|
expect(tableName(QuestObjective)).toBe('quest_objectives');
|
||||||
|
expect(tableName(NpcQuestAssignment)).toBe('npc_quest_assignments');
|
||||||
|
expect(tableName(CharacterQuest)).toBe('character_quests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the enum types the migration declares', () => {
|
||||||
|
// A mismatch here is invisible until TypeORM writes a cast at runtime, so
|
||||||
|
// it is worth asserting alongside the SQL-string migration spec.
|
||||||
|
expect(columnFor(QuestObjective, 'type')?.options.enumName).toBe(
|
||||||
|
'quest_objective_type_enum',
|
||||||
|
);
|
||||||
|
expect(columnFor(NpcQuestAssignment, 'role')?.options.enumName).toBe(
|
||||||
|
'npc_quest_role_enum',
|
||||||
|
);
|
||||||
|
expect(columnFor(CharacterQuest, 'status')?.options.enumName).toBe(
|
||||||
|
'character_quest_status_enum',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the objective effect columns on the objective', () => {
|
||||||
|
const names = columnNames(QuestObjective);
|
||||||
|
|
||||||
|
expect(names).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'advanceWhenBlocked',
|
||||||
|
'consumeOnComplete',
|
||||||
|
'grantsLootBagKey',
|
||||||
|
'setsFlagKey',
|
||||||
|
'setsFlagNpcKey',
|
||||||
|
'npcLine',
|
||||||
|
'hintText',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores no per-objective progress on the character', () => {
|
||||||
|
// Progress is derived from owned quantity (slice §11). A stored counter
|
||||||
|
// here would be the thing that lies about the inventory.
|
||||||
|
const names = columnNames(CharacterQuest);
|
||||||
|
|
||||||
|
expect(names).toContain('currentObjectiveIndex');
|
||||||
|
expect(names).not.toContain('progress');
|
||||||
|
expect(names).not.toContain('objectiveProgress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces one quest row per character per quest', () => {
|
||||||
|
expect(uniqueIndexFor(CharacterQuest, ['characterId', 'questId'])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps objective keys and order unique within a quest', () => {
|
||||||
|
expect(uniqueIndexFor(QuestObjective, ['questId', 'key'])).toBe(true);
|
||||||
|
expect(uniqueIndexFor(QuestObjective, ['questId', 'orderIndex'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets one NPC hold several roles for one quest', () => {
|
||||||
|
expect(
|
||||||
|
uniqueIndexFor(NpcQuestAssignment, ['npcId', 'questId', 'role']),
|
||||||
|
).toBe(true);
|
||||||
|
// The pair alone must NOT be unique, or the warden could not both offer
|
||||||
|
// and receive (NPC spec §14).
|
||||||
|
expect(uniqueIndexFor(NpcQuestAssignment, ['npcId', 'questId'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the enum values the content vocabulary needs', () => {
|
||||||
|
expect(Object.values(QuestObjectiveType).sort()).toEqual([
|
||||||
|
'COLLECT_ITEM',
|
||||||
|
'TALK_TO_NPC',
|
||||||
|
]);
|
||||||
|
expect(Object.values(NpcQuestRole).sort()).toEqual([
|
||||||
|
'OFFER',
|
||||||
|
'PROGRESS',
|
||||||
|
'TURN_IN',
|
||||||
|
]);
|
||||||
|
expect(Object.values(CharacterQuestStatus).sort()).toEqual([
|
||||||
|
'ACTIVE',
|
||||||
|
'COMPLETED',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
apps/api/src/quests/entities/quest-objective.entity.ts
Normal file
135
apps/api/src/quests/entities/quest-objective.entity.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { QuestObjectiveType } from '../quest.types';
|
||||||
|
import { QuestDefinition } from './quest-definition.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ordered step of a quest, and everything that step does (Slice 0.9 §10).
|
||||||
|
*
|
||||||
|
* The effect columns are what keep `QuestService` a state machine instead of a
|
||||||
|
* switch on quest keys: a step *declares* that it sets a flag, hands over a
|
||||||
|
* bag, or consumes what it asked for, and the service applies whatever it finds
|
||||||
|
* (AGENTS.md §9). Adding the next quest is then content work.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'quest_objectives' })
|
||||||
|
@Index('IDX_quest_objectives_quest_key', ['questId', 'key'], { unique: true })
|
||||||
|
@Index('IDX_quest_objectives_quest_order', ['questId', 'orderIndex'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class QuestObjective {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quest_id', type: 'uuid' })
|
||||||
|
questId!: string;
|
||||||
|
|
||||||
|
/** Unique within its quest, not globally -- two quests may both "turn-in". */
|
||||||
|
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'order_index', type: 'integer' })
|
||||||
|
orderIndex!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: QuestObjectiveType,
|
||||||
|
enumName: 'quest_objective_type_enum',
|
||||||
|
})
|
||||||
|
type!: QuestObjectiveType;
|
||||||
|
|
||||||
|
/** An item key for a collect step, an NPC key for a talk step. */
|
||||||
|
@Column({ name: 'target_key', type: 'varchar', length: 100 })
|
||||||
|
targetKey!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'required_quantity', type: 'integer', default: 1 })
|
||||||
|
requiredQuantity!: number;
|
||||||
|
|
||||||
|
/** The objective line the quest UI shows (spec §12). */
|
||||||
|
@Column({ name: 'description', type: 'varchar', length: 255 })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
/** What the NPC says when this step is performed (spec §5, §6, §8). */
|
||||||
|
@Column({ name: 'npc_line', type: 'text', nullable: true })
|
||||||
|
npcLine!: string | null;
|
||||||
|
|
||||||
|
/** Shown while the step is blocked rather than merely unfinished (spec §4). */
|
||||||
|
@Column({ name: 'hint_text', type: 'text', nullable: true })
|
||||||
|
hintText!: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capacity lesson, as one boolean (spec §4).
|
||||||
|
*
|
||||||
|
* A collect step with this set also counts as done when the character
|
||||||
|
* physically cannot carry more of the target's loot category. That is what
|
||||||
|
* hands a bagless player over to the warden at 1 / 5 instead of stranding
|
||||||
|
* them, and it is content because only the *first* pelt hunt should behave
|
||||||
|
* that way -- the second one has the bag and is expected to finish.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'advance_when_blocked', type: 'boolean', default: false })
|
||||||
|
advanceWhenBlocked!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this step's items are taken at turn-in (spec §8).
|
||||||
|
*
|
||||||
|
* Per step, not per quest: this chain asks for five pelts twice and must
|
||||||
|
* consume five, not ten.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'consume_on_complete', type: 'boolean', default: false })
|
||||||
|
consumeOnComplete!: boolean;
|
||||||
|
|
||||||
|
/** A `LootBagDefinition.key` this step hands over, once (spec §6, §11). */
|
||||||
|
@Column({
|
||||||
|
name: 'grants_loot_bag_key',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
grantsLootBagKey!: string | null;
|
||||||
|
|
||||||
|
/** A dialogue flag this step sets (spec §5). */
|
||||||
|
@Column({
|
||||||
|
name: 'sets_flag_key',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
setsFlagKey!: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* *Whose* state the flag is written to.
|
||||||
|
*
|
||||||
|
* Dialogue flags are per-NPC player state (NPC spec §7), so the warden's
|
||||||
|
* referral has to land on the character's Borin row -- that is the row the
|
||||||
|
* 0.8.5 `bypassConditions` gate reads when it decides whether the Basic Hide
|
||||||
|
* Bag offer is open (spec §6).
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'sets_flag_npc_key',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
setsFlagNpcKey!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => QuestDefinition, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'quest_id' })
|
||||||
|
quest!: QuestDefinition;
|
||||||
|
}
|
||||||
90
apps/api/src/quests/quest.types.ts
Normal file
90
apps/api/src/quests/quest.types.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { LootCategory } from '../items/loot-category.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one quest step asks of the player (Playable Slice 0.9 §10).
|
||||||
|
*
|
||||||
|
* Two kinds is the whole vocabulary this slice needs, and §10 explicitly asks
|
||||||
|
* for a step-based state machine rather than a branching narrative engine.
|
||||||
|
* A collect step is *observed* -- its progress is read from what the character
|
||||||
|
* currently owns -- while a talk step is *performed* through an endpoint.
|
||||||
|
*/
|
||||||
|
export enum QuestObjectiveType {
|
||||||
|
COLLECT_ITEM = 'COLLECT_ITEM',
|
||||||
|
TALK_TO_NPC = 'TALK_TO_NPC',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an NPC does for a quest (NPC spec §14).
|
||||||
|
*
|
||||||
|
* Modelled as a link row rather than an `npc.isQuestGiver` flag so one quest
|
||||||
|
* can span several people: the warden offers and receives, Borin sits in the
|
||||||
|
* middle. That is exactly §14's worked example.
|
||||||
|
*/
|
||||||
|
export enum NpcQuestRole {
|
||||||
|
OFFER = 'OFFER',
|
||||||
|
TURN_IN = 'TURN_IN',
|
||||||
|
PROGRESS = 'PROGRESS',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a character stands with one quest. */
|
||||||
|
export enum CharacterQuestStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A quest the character has not taken on yet is `AVAILABLE` (no row at all). */
|
||||||
|
export type QuestStatus = 'AVAILABLE' | 'ACTIVE' | 'COMPLETED';
|
||||||
|
|
||||||
|
export interface QuestObjectiveDto {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
type: QuestObjectiveType;
|
||||||
|
targetKey: string;
|
||||||
|
required: number;
|
||||||
|
current: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestDto {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: QuestStatus;
|
||||||
|
objectives: QuestObjectiveDto[];
|
||||||
|
currentObjectiveKey: string | null;
|
||||||
|
/**
|
||||||
|
* Why the active step cannot progress right now (spec §4, §12).
|
||||||
|
*
|
||||||
|
* The one piece of text that keeps a bagless player from reading "1 / 5" as
|
||||||
|
* a dead end. Null whenever the step is simply unfinished.
|
||||||
|
*/
|
||||||
|
hint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantedLootBagDto {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
lootCategory: LootCategory;
|
||||||
|
capacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestRewardDto {
|
||||||
|
factionKey: string | null;
|
||||||
|
reputation: number;
|
||||||
|
silver: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestConsumedItemDto {
|
||||||
|
itemKey: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestInteractionResultDto {
|
||||||
|
quest: QuestDto;
|
||||||
|
/** What the NPC says for the step just performed (spec §5, §6, §8). */
|
||||||
|
npcLine: string | null;
|
||||||
|
/** Non-null only on the step that hands a bag over (spec §12 Bag UI). */
|
||||||
|
grantedBag: GrantedLootBagDto | null;
|
||||||
|
consumedItems: QuestConsumedItemDto[];
|
||||||
|
rewards: QuestRewardDto | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user