This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -20,6 +20,8 @@ export class AddHpRegeneration1792000000000 implements MigrationInterface {
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
await queryRunner.query(
'ALTER TABLE "characters" DROP COLUMN "hp_regen_since"',
);
}
}

View File

@@ -0,0 +1,100 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CompleteBurnedRoad1793000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Monster content: mechanics and atmosphere as data (spec §4, §8) ---
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
);
await queryRunner.query(
`ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb NOT NULL DEFAULT '{}'::jsonb`,
);
// --- No direct currency from a kill (spec §7) ---
// Slice 0.6.5 kept `silver_min`/`silver_max` as a deliberate carve-out for
// a possible lore-valid direct drop. Playable Slice 0.7 V2 closes that:
// normal kills grant no Silver at all, and Silver reaches the player
// through merchant/turn-in exchange instead. Nothing reads these columns
// any more, so they go rather than sit as a path that could quietly start
// paying out again. Every seeded monster already had them at 0, so no
// player balance moves with this.
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
);
// --- Status effects (spec §4: Bleeding) ---
await queryRunner.query(
`CREATE TYPE "status_effect_type_enum" AS ENUM ('BLEED')`,
);
await queryRunner.query(
'ALTER TABLE "combat_events" ADD COLUMN "status_effect" "status_effect_type_enum"',
);
// Postgres allows ADD VALUE inside a transaction as long as the new value
// is not also used in it; this migration only declares them. Same
// technique as 1790000000000-ExtendCombatEventTypes.ts.
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_APPLIED'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_DAMAGE'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_EXPIRED'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, the same tradeoff the earlier enum migration makes.
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', '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"',
);
await queryRunner.query(
'ALTER TABLE "combat_events" DROP COLUMN "status_effect"',
);
await queryRunner.query('DROP TYPE "status_effect_type_enum"');
// The dropped currency columns come back at 0 -- their pre-image was 0 for
// every seeded monster, and no reward row recorded anything else.
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" ALTER COLUMN "silver_granted" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_min" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_max" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLootBags1794000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Categories as content (spec §3, §5) ---
await queryRunner.query(
`CREATE TYPE "loot_category_enum" AS ENUM ('HIDE', 'RAIDER_TROPHY')`,
);
await queryRunner.query(
`CREATE TYPE "monster_category_enum" AS ENUM ('BEAST', 'HUMANOID')`,
);
// Nullable: only trade goods belong to a carrying bucket. Equipment and
// consumables stay outside the system entirely (spec §8).
await queryRunner.query(
'ALTER TABLE "item_definitions" ADD COLUMN "loot_category" "loot_category_enum"',
);
// Every monster has a category, so this backfills before going NOT NULL.
// BEAST is the safe default for the pre-existing rows: the seed
// immediately reclassifies the two humanoids by their stable keys, and a
// wrong classification changes no behaviour in this slice — nothing
// branches on monster category yet.
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "monster_category" "monster_category_enum"',
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'BEAST' WHERE "monster_category" IS NULL`,
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'HUMANOID' WHERE "key" IN ('road-bandit', 'charred-looter')`,
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "monster_category" SET NOT NULL',
);
// --- Bags (spec §6) ---
await queryRunner.query(`CREATE TABLE "loot_bag_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"loot_category" "loot_category_enum" NOT NULL,
"capacity" integer NOT NULL,
"icon_path" character varying(255) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_bag_definitions" PRIMARY KEY ("id"),
CONSTRAINT "CHK_loot_bag_definitions_capacity" CHECK ("capacity" >= 1)
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_bag_definitions_key" ON "loot_bag_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "character_loot_bags" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"loot_bag_definition_id" uuid NOT NULL,
"active" 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_character_loot_bags" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_loot_bags_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_loot_bags_definition" FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
// A character holds any given bag at most once. "One *active* bag per
// category" is a service-level rule instead: the category lives on the
// definition, and copying it here to get a partial unique index would
// duplicate content into player state (AGENTS §7).
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition" ON "character_loot_bags" ("character_id", "loot_bag_definition_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_character_loot_bags_character" ON "character_loot_bags" ("character_id")',
);
// --- Left-behind loot is part of the record (spec §9) ---
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
);
// A drop that was refused outright creates no stack to point at.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
);
// Slice 0.4 required quantity >= 1, because back then a reward row could
// only mean "you got this". A fully refused drop is granted 0, so the
// floor moves to 0 -- but the row must still record *something*, hence
// the replacement constraint: no row may be all zeroes.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 0 AND "quantity_left_behind" >= 0 AND "quantity" + "quantity_left_behind" >= 1)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rows recording a fully-rejected drop have no character item and cannot
// satisfy the restored NOT NULL, so they go with the feature that created
// them. Nothing else can produce a null there.
await queryRunner.query(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1)`,
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" SET NOT NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP COLUMN "quantity_left_behind"',
);
await queryRunner.query('DROP INDEX "IDX_character_loot_bags_character"');
await queryRunner.query(
'DROP INDEX "IDX_character_loot_bags_character_definition"',
);
await queryRunner.query('DROP TABLE "character_loot_bags"');
await queryRunner.query('DROP INDEX "IDX_loot_bag_definitions_key"');
await queryRunner.query('DROP TABLE "loot_bag_definitions"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "monster_category"',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" DROP COLUMN "loot_category"',
);
await queryRunner.query('DROP TYPE "monster_category_enum"');
await queryRunner.query('DROP TYPE "loot_category_enum"');
}
}

View File

@@ -0,0 +1,266 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The NPC system and the merchant trade-in loop
* (NPC Specification V1 §29; Playable Slice 0.8).
*
* Also retires `turn_in_definitions` from Slice 0.6.5. Everything it could
* sell is now sold through the merchant instead -- at a better price, and with
* reputation and renown attached -- so the two are not left running as
* parallel payout paths for the same pelt (slice 0.8 §6).
*/
export class CreateNpcSystem1795000000000 implements MigrationInterface {
name = 'CreateNpcSystem1795000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "npc_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"title" character varying(150),
"description" text,
"location_id" uuid NOT NULL,
"faction_key" character varying(100),
"portrait_path" character varying(255) NOT NULL,
"artwork_path" character varying(255),
"capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_definitions_location" FOREIGN KEY ("location_id")
REFERENCES "location_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_definitions_location" ON "npc_definitions" ("location_id")`,
);
await queryRunner.query(`
CREATE TABLE "dialogue_nodes" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"npc_id" uuid NOT NULL,
"key" character varying(100) NOT NULL,
"text" text NOT NULL,
"priority" integer NOT NULL,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"actions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"responses" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_dialogue_nodes" PRIMARY KEY ("id"),
CONSTRAINT "FK_dialogue_nodes_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_dialogue_nodes_npc_key" ON "dialogue_nodes" ("npc_id", "key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_dialogue_nodes_npc_priority" ON "dialogue_nodes" ("npc_id", "priority")`,
);
await queryRunner.query(`
CREATE TABLE "character_npc_states" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"character_id" uuid NOT NULL,
"npc_id" uuid NOT NULL,
"first_met_at" TIMESTAMP WITH TIME ZONE,
"last_interaction_at" TIMESTAMP WITH TIME ZONE,
"flags" jsonb NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_npc_states" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_npc_states_character" FOREIGN KEY ("character_id")
REFERENCES "characters"("id") ON DELETE CASCADE,
CONSTRAINT "FK_character_npc_states_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_character_npc_states_character_npc" ON "character_npc_states" ("character_id", "npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_shops" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) 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_shops" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_shops_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_shops_npc" ON "npc_shops" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "shop_offers" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"shop_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"currency_type" character varying(50) NOT NULL,
"price" integer NOT NULL,
"quantity" integer NOT NULL DEFAULT 1,
"repeatable" boolean NOT NULL DEFAULT true,
"sort_order" integer NOT NULL DEFAULT 0,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"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_shop_offers" PRIMARY KEY ("id"),
CONSTRAINT "CHK_shop_offers_price" CHECK ("price" >= 0),
CONSTRAINT "CHK_shop_offers_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_shop_offers_shop" FOREIGN KEY ("shop_id")
REFERENCES "npc_shops"("id") ON DELETE CASCADE,
CONSTRAINT "FK_shop_offers_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_exchange_profiles" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) 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_exchange_profiles" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_exchange_profiles_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_exchange_profiles_npc" ON "npc_exchange_profiles" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "exchange_rules" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"profile_id" uuid NOT NULL,
"input_item_id" uuid NOT NULL,
"input_quantity" integer NOT NULL DEFAULT 1,
"faction_id" uuid NOT NULL,
"silver_reward" integer NOT NULL DEFAULT 0,
"region_reputation_reward" integer NOT NULL DEFAULT 0,
"renown_milestone_key" character varying(100),
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"sort_order" 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_exchange_rules" PRIMARY KEY ("id"),
CONSTRAINT "CHK_exchange_rules_input_quantity" CHECK ("input_quantity" >= 1),
CONSTRAINT "CHK_exchange_rules_rewards" CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0),
CONSTRAINT "FK_exchange_rules_profile" FOREIGN KEY ("profile_id")
REFERENCES "npc_exchange_profiles"("id") ON DELETE CASCADE,
CONSTRAINT "FK_exchange_rules_item" FOREIGN KEY ("input_item_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_exchange_rules_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")`,
);
// Retires Slice 0.6.5's `turn_in_definitions`.
//
// Nothing is carried across: exchange rules are seeded content, and the
// seed re-establishes an equivalent (better paying, reputation- and
// renown-aware) rule for every item that used to be turned in. Migrations
// run before seeds, so there is no exchange profile to attach carried rows
// to at this point anyway.
//
// Dropped rather than left standing, because a dormant second payout path
// for the same pelts is exactly the competing progression model slice 0.8
// §6 rules out -- and a table nothing reads is one someone re-wires later.
await queryRunner.query(`DROP TABLE "turn_in_definitions"`);
// Trading the last of a stack deletes the `character_items` row, and
// Slice 0.4 pinned reward bookkeeping to it with ON DELETE RESTRICT --
// correct when nothing could ever consume a stack, and a hard 500 the
// moment something could. This slice is that something.
//
// SET NULL keeps the reward history intact (it still records what
// dropped) while letting the stack itself go. The column has been
// nullable since 0.7.5, which already used null to mean "no live stack".
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE SET NULL ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rebuilt exactly as migration 1791 left it, so a rollback lands on the
// schema that migration expects to own.
await queryRunner.query(`
CREATE TABLE "turn_in_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"item_definition_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"silver_reward_per_item" integer NOT NULL,
"reputation_reward_per_item" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT true,
"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_turn_in_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_turn_in_definitions_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")`,
);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE RESTRICT ON UPDATE NO ACTION
`);
await queryRunner.query(`DROP TABLE "exchange_rules"`);
await queryRunner.query(`DROP TABLE "npc_exchange_profiles"`);
await queryRunner.query(`DROP TABLE "shop_offers"`);
await queryRunner.query(`DROP TABLE "npc_shops"`);
await queryRunner.query(`DROP TABLE "character_npc_states"`);
await queryRunner.query(`DROP TABLE "dialogue_nodes"`);
await queryRunner.query(`DROP TABLE "npc_definitions"`);
}
}

View File

@@ -7,7 +7,8 @@ describe('characters.hp_regen_since schema', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
candidate.target === Character &&
candidate.propertyName === 'hpRegenSince',
);
expect(column).toBeDefined();

View File

@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CompleteBurnedRoad1793000000000 } from './1793000000000-CompleteBurnedRoad';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { StatusEffectType } from '../../combat/status-effect.enum';
describe('CompleteBurnedRoad1793000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CompleteBurnedRoad1793000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CompleteBurnedRoad1793000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('adds the monster content columns the slice drives its mechanics from', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb',
),
]),
);
});
it('defaults abilities to an empty object so existing monsters stay plain attackers', async () => {
const up = await runUp();
const abilities = up.find((sql) => sql.includes('"abilities"'));
expect(abilities).toContain("DEFAULT '{}'::jsonb");
expect(abilities).toContain('NOT NULL');
});
it('drops every direct currency reward path from a kill', async () => {
const up = await runUp();
// Spec §7: a normal kill grants no Silver. Leaving the columns in place
// would leave a path that could quietly start paying out again.
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
),
]),
);
});
it('creates the status effect type and hangs it off combat_events', async () => {
const up = await runUp();
const createIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "status_effect_type_enum"'),
);
const columnIndex = up.findIndex((sql) =>
sql.includes('ADD COLUMN "status_effect"'),
);
expect(createIndex).toBeGreaterThanOrEqual(0);
expect(columnIndex).toBeGreaterThanOrEqual(0);
// The column cannot reference a type that does not exist yet.
expect(createIndex).toBeLessThan(columnIndex);
});
it('extends the combat event enum with the status effect events', async () => {
const up = await runUp();
for (const value of ['STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED']) {
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
`ALTER TYPE "combat_event_type_enum" ADD VALUE '${value}'`,
),
]),
);
}
});
it('rebuilds the combat event enum without the new values on the way down', async () => {
const down = await runDown();
const rebuilt = down.find((sql) =>
sql.includes('CREATE TYPE "combat_event_type_enum"'),
);
expect(rebuilt).toBeDefined();
expect(rebuilt).not.toContain('STATUS_APPLIED');
// Everything the previous migrations added must survive the rollback.
for (const value of [
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]) {
expect(rebuilt).toContain(value);
}
});
it('restores every dropped column on the way down', async () => {
const down = await runDown();
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
),
]),
);
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TYPE "status_effect_type_enum"'),
]),
);
});
});
describe('slice 0.7 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores monster abilities as jsonb', () => {
expect(column(MonsterDefinition, 'abilities')?.options.type).toBe('jsonb');
});
it('allows a monster without a flavor line', () => {
const flavorText = column(MonsterDefinition, 'flavorText');
expect(flavorText?.options.type).toBe('text');
expect(flavorText?.options.nullable).toBe(true);
});
it('no longer models a currency range on a monster', () => {
expect(column(MonsterDefinition, 'silverMin')).toBeUndefined();
expect(column(MonsterDefinition, 'silverMax')).toBeUndefined();
});
it('no longer models granted Silver on a combat reward', () => {
expect(column(CombatReward, 'silverGranted')).toBeUndefined();
});
it('tags a combat event with the status effect it concerns', () => {
const statusEffect = column(CombatEvent, 'statusEffect');
expect(statusEffect?.options.enum).toBe(StatusEffectType);
expect(statusEffect?.options.nullable).toBe(true);
});
it('includes the status effect event types', () => {
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'STATUS_APPLIED',
'STATUS_DAMAGE',
'STATUS_EXPIRED',
]),
);
});
});

View File

@@ -79,8 +79,11 @@ describe('loot and rewards schema', () => {
propertyName: 'combatReward',
target: CombatRewardItem,
}),
// SET NULL since Slice 0.8: trading the last of a stack deletes the
// character_items row, and RESTRICT made that fail. The reward record
// survives with a null pointer rather than pinning the stack forever.
expect.objectContaining({
onDelete: 'RESTRICT',
onDelete: 'SET NULL',
propertyName: 'characterItem',
target: CombatRewardItem,
}),

View File

@@ -0,0 +1,223 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateLootBags1794000000000 } from './1794000000000-CreateLootBags';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { LootCategory } from '../../items/loot-category.enum';
import { MonsterCategory } from '../../monsters/monster-category.enum';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
describe('CreateLootBags1794000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateLootBags1794000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateLootBags1794000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('creates both category enums before the columns that use them', async () => {
const up = await runUp();
const lootTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "loot_category_enum"'),
);
const lootColumnIndex = up.findIndex((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
const monsterTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "monster_category_enum"'),
);
const monsterColumnIndex = up.findIndex((sql) =>
sql.includes('"monster_definitions" ADD COLUMN "monster_category"'),
);
expect(lootTypeIndex).toBeGreaterThanOrEqual(0);
expect(monsterTypeIndex).toBeGreaterThanOrEqual(0);
expect(lootTypeIndex).toBeLessThan(lootColumnIndex);
expect(monsterTypeIndex).toBeLessThan(monsterColumnIndex);
});
it('leaves loot_category nullable, because only trade goods have one', async () => {
const up = await runUp();
const column = up.find((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
expect(column).not.toContain('NOT NULL');
});
it('backfills monster_category BEFORE making it NOT NULL', async () => {
const up = await runUp();
const backfillIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'BEAST'`),
);
const humanoidIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'HUMANOID'`),
);
const notNullIndex = up.findIndex((sql) =>
sql.includes('"monster_category" SET NOT NULL'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0);
// Reversing these would fail the migration on any existing row.
expect(backfillIndex).toBeLessThan(notNullIndex);
expect(humanoidIndex).toBeLessThan(notNullIndex);
});
it('creates the bag tables with their ownership keys', async () => {
const up = await runUp();
const joined = up.join('\n');
expect(joined).toContain('CREATE TABLE "loot_bag_definitions"');
expect(joined).toContain('CREATE TABLE "character_loot_bags"');
// A character cannot hold the same bag twice.
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition"',
);
// Deleting a character takes their bags; a bag definition in use cannot
// be deleted out from under them.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
expect(joined).toContain(
'FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT',
);
});
it('refuses a bag that carries nothing', async () => {
const up = await runUp();
expect(up.join('\n')).toContain('CHECK ("capacity" >= 1)');
});
it('lets a reward item record a drop that was refused outright', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'"combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
),
expect.stringContaining(
'"combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
),
]),
);
});
it('lowers the quantity floor to 0 but still forbids an empty row', async () => {
const up = await runUp();
const constraint = up
.filter((sql) => sql.includes('CHK_combat_reward_items_quantity'))
.join(' ');
// Slice 0.4's `quantity >= 1` would reject a fully refused drop outright,
// which is how this surfaced: the victory 500'd instead of recording what
// was left behind.
expect(constraint).toContain('DROP CONSTRAINT');
expect(constraint).toContain('"quantity" >= 0');
// A row still has to mean something: granted 0 and refused 0 is nonsense.
expect(constraint).toContain('"quantity" + "quantity_left_behind" >= 1');
});
it('restores the original quantity floor on the way down', async () => {
const down = await runDown();
const restored = down.find((sql) =>
sql.includes('ADD CONSTRAINT "CHK_combat_reward_items_quantity"'),
);
expect(restored).toContain('"quantity" >= 1');
expect(restored).not.toContain('quantity_left_behind');
});
it('clears the rows that cannot satisfy the restored NOT NULL on the way down', async () => {
const down = await runDown();
const deleteIndex = down.findIndex((sql) =>
sql.includes(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
),
);
const notNullIndex = down.findIndex((sql) =>
sql.includes('"character_item_id" SET NOT NULL'),
);
expect(deleteIndex).toBeGreaterThanOrEqual(0);
// Without the delete first, the rollback would simply fail.
expect(deleteIndex).toBeLessThan(notNullIndex);
});
it('drops the enum types only after the columns using them are gone', async () => {
const down = await runDown();
const dropColumnIndex = down.findIndex((sql) =>
sql.includes('"item_definitions" DROP COLUMN "loot_category"'),
);
const dropTypeIndex = down.findIndex((sql) =>
sql.includes('DROP TYPE "loot_category_enum"'),
);
const dropBagTableIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "loot_bag_definitions"'),
);
expect(dropColumnIndex).toBeLessThan(dropTypeIndex);
// loot_bag_definitions.loot_category uses the same type.
expect(dropBagTableIndex).toBeLessThan(dropTypeIndex);
});
});
describe('slice 0.7.5 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('lets an item definition sit outside every loot category', () => {
const lootCategory = column(ItemDefinition, 'lootCategory');
expect(lootCategory?.options.enum).toBe(LootCategory);
expect(lootCategory?.options.nullable).toBe(true);
});
it('requires a category on every monster definition', () => {
const monsterCategory = column(MonsterDefinition, 'monsterCategory');
expect(monsterCategory?.options.enum).toBe(MonsterCategory);
expect(monsterCategory?.options.nullable).toBeUndefined();
});
it('models a bag definition as content with a category and a capacity', () => {
expect(column(LootBagDefinition, 'lootCategory')?.options.enum).toBe(
LootCategory,
);
expect(column(LootBagDefinition, 'capacity')?.options.type).toBe('integer');
});
it('models an owned bag as active-or-not player state', () => {
expect(column(CharacterLootBag, 'active')?.options.type).toBe('boolean');
expect(column(CharacterLootBag, 'characterId')?.options.type).toBe('uuid');
});
it('lets a reward item point at no character item', () => {
expect(column(CombatRewardItem, 'characterItemId')?.options.nullable).toBe(
true,
);
expect(column(CombatRewardItem, 'quantityLeftBehind')?.options.type).toBe(
'integer',
);
});
});

View File

@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateNpcSystem1795000000000 } from './1795000000000-CreateNpcSystem';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { CharacterNpcState } from '../../npcs/entities/character-npc-state.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
async function runUp(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateNpcSystem1795000000000().up(queryRunner);
return 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 CreateNpcSystem1795000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
describe('CreateNpcSystem1795000000000', () => {
it('creates every NPC-system table', async () => {
const joined = (await runUp()).join('\n');
for (const table of [
'npc_definitions',
'dialogue_nodes',
'character_npc_states',
'npc_shops',
'shop_offers',
'npc_exchange_profiles',
'exchange_rules',
]) {
expect(joined).toContain(`CREATE TABLE "${table}"`);
}
});
it('creates parent tables before the children that reference them', async () => {
const up = await runUp();
const indexOf = (needle: string) =>
up.findIndex((sql) => sql.includes(needle));
const npcs = indexOf('CREATE TABLE "npc_definitions"');
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "dialogue_nodes"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_shops"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_exchange_profiles"'));
expect(indexOf('CREATE TABLE "npc_shops"')).toBeLessThan(
indexOf('CREATE TABLE "shop_offers"'),
);
expect(indexOf('CREATE TABLE "npc_exchange_profiles"')).toBeLessThan(
indexOf('CREATE TABLE "exchange_rules"'),
);
});
it('gives every NPC-facing row a stable unique business key (spec §4)', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")',
);
});
it('keeps one exchange rule per item, so a good has one price', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
);
});
it('refuses content that would pay out nonsense', async () => {
const joined = (await runUp()).join('\n');
// A rule trading zero items would loop forever against any stack.
expect(joined).toContain('CHECK ("input_quantity" >= 1)');
expect(joined).toContain(
'CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0)',
);
expect(joined).toContain('CHECK ("price" >= 0)');
});
it('cascades player-owned rows and protects referenced content', async () => {
// Constraints are written across two lines for readability, so compare
// against whitespace-collapsed SQL rather than the literal formatting.
const joined = (await runUp()).join('\n').replace(/\s+/g, ' ');
// Deleting a character takes their NPC state with it.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
// An item that is priced somewhere cannot be deleted out from under it.
expect(joined).toContain(
'FOREIGN KEY ("input_item_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT',
);
expect(joined).toContain(
'FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT',
);
});
it('retires the turn-in table that the exchange replaces', async () => {
const up = await runUp();
// Slice 0.6.5's turn-in and this exchange both convert a pelt into silver
// and reputation. Leaving both would be two prices for one pelt
// (slice 0.8 §6).
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
]),
);
});
it('rebuilds the turn-in table on the way down', async () => {
const down = await runDown();
const joined = down.join('\n');
expect(joined).toContain('CREATE TABLE "turn_in_definitions"');
expect(joined).toContain('"silver_reward_per_item" integer NOT NULL');
});
it('drops children before parents on the way down', async () => {
const down = await runDown();
const indexOf = (needle: string) =>
down.findIndex((sql) => sql.includes(needle));
expect(indexOf('DROP TABLE "exchange_rules"')).toBeLessThan(
indexOf('DROP TABLE "npc_exchange_profiles"'),
);
expect(indexOf('DROP TABLE "shop_offers"')).toBeLessThan(
indexOf('DROP TABLE "npc_shops"'),
);
expect(indexOf('DROP TABLE "dialogue_nodes"')).toBeLessThan(
indexOf('DROP TABLE "npc_definitions"'),
);
});
});
describe('slice 0.8 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores NPC capabilities as data rather than as subclasses (spec §2)', () => {
expect(column(NpcDefinition, 'capabilities')?.options.type).toBe('jsonb');
});
it('lets a dialogue node carry its own conditions and priority (spec §11)', () => {
expect(column(DialogueNode, 'priority')?.options.type).toBe('integer');
expect(column(DialogueNode, 'conditions')?.options.type).toBe('jsonb');
});
it('keeps per-character NPC state off the shared definition (spec §7)', () => {
expect(column(CharacterNpcState, 'characterId')?.options.type).toBe('uuid');
expect(column(CharacterNpcState, 'flags')?.options.type).toBe('jsonb');
// Personal relationship is explicitly out of V1 scope (spec §35).
expect(column(CharacterNpcState, 'relationValue')).toBeUndefined();
});
it('gives every shop offer its own conditions (spec §16)', () => {
expect(column(ShopOffer, 'conditions')?.options.type).toBe('jsonb');
});
it('names a renown milestone instead of paying renown per item', () => {
// Renown is a 1-15 power rank recomputed from a curve (Slice 0.6.5 §4),
// so it is awarded by milestone, not accumulated per pelt.
const milestone = column(ExchangeRule, 'renownMilestoneKey');
expect(milestone?.options.nullable).toBe(true);
expect(column(ExchangeRule, 'silverReward')?.options.type).toBe('integer');
});
it('keeps the exchange profile separate from the shop (spec §17)', () => {
expect(column(NpcExchangeProfile, 'key')?.options.type).toBe('varchar');
expect(column(NpcShop, 'key')?.options.type).toBe('varchar');
});
});

View File

@@ -8,7 +8,6 @@ import { ReputationFaction } from '../../reputation/entities/reputation-faction.
import { CharacterReputation } from '../../reputation/entities/character-reputation.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
@@ -71,7 +70,7 @@ describe('Slice 0.6.5 entity metadata', () => {
).toBe(true);
});
it('TurnInDefinition has a unique key', () => {
expect(uniqueIndexFor(TurnInDefinition, ['key'])).toBe(true);
});
// TurnInDefinition's metadata assertion lived here until Slice 0.8 retired
// that entity in favour of ExchangeRule. Its replacement is covered by
// `npc-system.migration.spec.ts`.
});