docs
This commit is contained in:
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
133
apps/api/src/database/migrations/1794000000000-CreateLootBags.ts
Normal file
133
apps/api/src/database/migrations/1794000000000-CreateLootBags.ts
Normal 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"');
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
223
apps/api/src/database/migrations/loot-bags.migration.spec.ts
Normal file
223
apps/api/src/database/migrations/loot-bags.migration.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
195
apps/api/src/database/migrations/npc-system.migration.spec.ts
Normal file
195
apps/api/src/database/migrations/npc-system.migration.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user