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`.
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../../items/item-rarity.enum';
|
||||
import { ItemType } from '../../items/item-type.enum';
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ItemKey,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
|
||||
export interface SeedItemDefinition {
|
||||
@@ -16,6 +19,7 @@ export interface SeedItemDefinition {
|
||||
type: ItemType;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
rarity: ItemRarity;
|
||||
lootCategory: LootCategory | null;
|
||||
tier: number;
|
||||
weaponDamage: number;
|
||||
bonusHp: number;
|
||||
@@ -38,6 +42,8 @@ function item(
|
||||
'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'
|
||||
>
|
||||
> = {},
|
||||
// Only trade goods carry one; everything else is uncapped (0.7.5 §4, §8).
|
||||
lootCategory: LootCategory | null = null,
|
||||
): SeedItemDefinition {
|
||||
return {
|
||||
id: ITEM_IDS[key],
|
||||
@@ -47,6 +53,7 @@ function item(
|
||||
type,
|
||||
equipmentSlot,
|
||||
rarity,
|
||||
lootCategory,
|
||||
tier: 1,
|
||||
weaponDamage: stats.weaponDamage ?? 0,
|
||||
bonusHp: stats.bonusHp ?? 0,
|
||||
@@ -161,23 +168,49 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
// A Trade Good (spec §17): turned in to the Border Watch for Silver and
|
||||
// reputation rather than crafted with.
|
||||
// Trade Goods (spec §17): turned in to the Border Watch for Silver and
|
||||
// reputation rather than crafted with. Playable Slice 0.7 V2 §5 makes one
|
||||
// of these the guaranteed drop of every Burned Road enemy, which is why
|
||||
// there are now four of them -- one per monster.
|
||||
item(
|
||||
'ash-pelt',
|
||||
'Ash Pelt',
|
||||
'Ashen Pelt',
|
||||
'Singed pelt, tough as leather and grey with drifting ash.',
|
||||
ItemType.TRADE_GOOD,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.HIDE,
|
||||
),
|
||||
item(
|
||||
'tough-hide',
|
||||
'Tough Hide',
|
||||
'Road-hound hide, scarred over so often it barely takes a blade.',
|
||||
ItemType.TRADE_GOOD,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.HIDE,
|
||||
),
|
||||
item(
|
||||
'bandit-insignia',
|
||||
'Bandit Insignia',
|
||||
'Raider Insignia',
|
||||
'A roughly stamped token marking a road bandit as one of their band.',
|
||||
ItemType.TROPHY,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.RAIDER_TROPHY,
|
||||
),
|
||||
item(
|
||||
'charred-raider-insignia',
|
||||
'Charred Raider Insignia',
|
||||
'The same stamped token, warped by heat until the band mark is barely legible.',
|
||||
ItemType.TROPHY,
|
||||
null,
|
||||
ItemRarity.RARE,
|
||||
{},
|
||||
LootCategory.RAIDER_TROPHY,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -188,6 +221,16 @@ export const LOOT_TABLES = [
|
||||
key: 'road-bandit-loot',
|
||||
name: 'Road Bandit Loot',
|
||||
},
|
||||
{
|
||||
id: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
key: 'wild-road-dog-loot',
|
||||
name: 'Feral Road Hound Loot',
|
||||
},
|
||||
{
|
||||
id: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
key: 'charred-looter-loot',
|
||||
name: 'Charred Raider Loot',
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedLootTableEntry {
|
||||
@@ -218,17 +261,33 @@ function entry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28.
|
||||
* Every entry is an independent roll (spec §17), rolled in `position` order.
|
||||
* Equipment drop chances come from
|
||||
* docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28; the trade goods
|
||||
* are guaranteed by Playable Slice 0.7 V2 §5. Every entry is an independent
|
||||
* roll (spec §17), rolled in `position` order, so the guaranteed trade good
|
||||
* and the equipment chance never influence each other.
|
||||
*
|
||||
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is
|
||||
* omitted here because Slice 0.4 implements no consumables (spec §16).
|
||||
* `position` values of pre-existing entries are left where they were: the
|
||||
* seed upserts on (lootTableId, itemDefinitionId) while `position` carries a
|
||||
* unique index, so renumbering an existing table in one statement would
|
||||
* collide with itself.
|
||||
*
|
||||
* DEFERRED: the Feral Road Hound is also meant to drop a Small Healing Potion
|
||||
* "if potions are already lootable" (spec §6). They are not — combat potions
|
||||
* are still a fixed per-fight count, not inventory-backed — so the entry waits
|
||||
* rather than seeding an item that does nothing.
|
||||
*/
|
||||
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '1.0000'),
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.4000'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '1.0000'),
|
||||
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '1.0000'),
|
||||
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'),
|
||||
];
|
||||
|
||||
@@ -13,9 +13,17 @@ export const ITEM_IDS = {
|
||||
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
|
||||
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
|
||||
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
|
||||
'tough-hide': '50000000-0000-4000-8000-00000000000e',
|
||||
'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f',
|
||||
} as const;
|
||||
|
||||
export type ItemKey = keyof typeof ITEM_IDS;
|
||||
|
||||
// One table per monster (Playable Slice 0.7 V2 spec §5): each Burned Road
|
||||
// enemy now has its own guaranteed trade good, so they can no longer share.
|
||||
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
|
||||
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
|
||||
export const WILD_ROAD_DOG_LOOT_TABLE_ID =
|
||||
'60000000-0000-4000-8000-000000000003';
|
||||
export const CHARRED_LOOTER_LOOT_TABLE_ID =
|
||||
'60000000-0000-4000-8000-000000000004';
|
||||
|
||||
@@ -126,14 +126,15 @@ export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
|
||||
// promise a drop the roll does not guarantee (spec §8, "Mögliche
|
||||
// Belohnungen").
|
||||
//
|
||||
// Silver and experience were both dropped from this preview by slice 0.6.5.
|
||||
// Experience no longer exists as a concept at all (spec §1), and every
|
||||
// monster seeded on this road now rolls silverMin/silverMax = 0 (design R7),
|
||||
// so a kill here yields neither. Silver reaches the player through turn-ins
|
||||
// instead. Leaving either entry in place would break this list's own rule.
|
||||
// Silver and experience were both dropped from this preview by slice 0.6.5,
|
||||
// and Playable Slice 0.7 V2 §7 makes that permanent: a normal kill grants
|
||||
// no XP, Silver, regional reputation or World Renown, and the monster
|
||||
// definitions no longer carry a currency range at all. Silver reaches the
|
||||
// player through turn-ins instead. Leaving either entry in place would
|
||||
// break this list's own rule.
|
||||
localRewardPreview: [
|
||||
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
|
||||
{ key: 'material', label: 'Material', iconKey: 'material' },
|
||||
{ key: 'material', label: 'Trade Goods', iconKey: 'material' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -171,6 +172,20 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||
resultText:
|
||||
'"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."',
|
||||
},
|
||||
// Borin stands at the gate rather than deeper in a town that does not
|
||||
// exist yet as a location. Carries `npcKey` instead of result text, so
|
||||
// this hotspot opens his screen (Playable Slice 0.8 §2).
|
||||
{
|
||||
key: 'borin',
|
||||
title: 'Borin, Quartermaster',
|
||||
actionLabel: 'Trade',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 30,
|
||||
yPercent: 66,
|
||||
enabled: true,
|
||||
npcKey: 'borin-quartermaster',
|
||||
},
|
||||
{
|
||||
key: 'south-road',
|
||||
title: 'Road South',
|
||||
@@ -192,6 +207,16 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||
enabled: true,
|
||||
poiKey: 'gate-watch',
|
||||
},
|
||||
{
|
||||
key: 'trade-with-borin',
|
||||
label: 'Trade with Borin',
|
||||
description: 'Sell goods, buy supplies',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
enabled: true,
|
||||
poiKey: 'borin',
|
||||
npcKey: 'borin-quartermaster',
|
||||
},
|
||||
{
|
||||
key: 'read-notice',
|
||||
label: 'Read the notice',
|
||||
|
||||
38
apps/api/src/database/seeds/loot-bag-content.ts
Normal file
38
apps/api/src/database/seeds/loot-bag-content.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
|
||||
export const BASIC_HIDE_BAG_ID = 'a0000000-0000-4000-8000-000000000001';
|
||||
export const BASIC_TROPHY_POUCH_ID = 'a0000000-0000-4000-8000-000000000002';
|
||||
|
||||
export interface SeedLootBagDefinition {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
lootCategory: LootCategory;
|
||||
capacity: number;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The starter bag per category (Playable Slice 0.7.5 §7).
|
||||
*
|
||||
* Capacity 5 against a bagless default of 1: enough that a hunt is worth
|
||||
* making, small enough that the trip still ends.
|
||||
*/
|
||||
export const LOOT_BAG_DEFINITIONS: SeedLootBagDefinition[] = [
|
||||
{
|
||||
id: BASIC_HIDE_BAG_ID,
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
lootCategory: LootCategory.HIDE,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-hide-bag.png',
|
||||
},
|
||||
{
|
||||
id: BASIC_TROPHY_POUCH_ID,
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||
},
|
||||
];
|
||||
335
apps/api/src/database/seeds/npc-content.ts
Normal file
335
apps/api/src/database/seeds/npc-content.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameCondition,
|
||||
GameConditionType,
|
||||
} from '../../conditions/game-condition.types';
|
||||
import { NpcCapability } from '../../npcs/npc.types';
|
||||
import type {
|
||||
DialogueAction,
|
||||
DialogueResponseContent,
|
||||
} from '../../npcs/npc.types';
|
||||
import { ITEM_IDS } from './item.constants';
|
||||
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
|
||||
|
||||
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
|
||||
export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
|
||||
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
|
||||
|
||||
export const BORIN_KEY = 'borin-quartermaster';
|
||||
export const BORIN_SHOP_KEY = 'borin-supplies';
|
||||
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
|
||||
|
||||
/**
|
||||
* The Renown 2 milestone (Playable Slice 0.6.5 §6).
|
||||
*
|
||||
* 0.6.5 names "first meaningful trophy returned" as what should move a
|
||||
* character from Renown 1 to 2, and this is the first slice where returning a
|
||||
* trophy is possible at all. Non-repeatable: it fires on the first trade and
|
||||
* never again, which is how routine trading keeps paying silver and reputation
|
||||
* without inflating a power rank (0.6.5 §2.2).
|
||||
*/
|
||||
export const FIRST_TRADE_MILESTONE_KEY = 'first-goods-returned';
|
||||
export const FIRST_TRADE_MILESTONE_ID = 'b3000000-0000-4000-8000-000000000001';
|
||||
|
||||
export interface SeedRenownMilestone {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
renownReward: number;
|
||||
repeatable: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const RENOWN_MILESTONES: SeedRenownMilestone[] = [
|
||||
{
|
||||
id: FIRST_TRADE_MILESTONE_ID,
|
||||
key: FIRST_TRADE_MILESTONE_KEY,
|
||||
name: 'Something Worth Bringing Back',
|
||||
description:
|
||||
'You returned from the Ashen Fields with goods worth trading, and the Border Watch noticed.',
|
||||
renownReward: 1,
|
||||
repeatable: false,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcDefinition {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
locationKey: string;
|
||||
factionKey: string | null;
|
||||
portraitPath: string;
|
||||
artworkPath: string | null;
|
||||
capabilities: NpcCapability[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borin, the Graufurt quartermaster (Playable Slice 0.8 §2).
|
||||
*
|
||||
* Placed at `south-gate` because that is Graufurt in the current content --
|
||||
* the only safe, non-hunting location, named "Graufurt South Gate". No new
|
||||
* town location is invented for him; when Graufurt proper is built out he
|
||||
* moves by changing this one key.
|
||||
*
|
||||
* His capabilities list MERCHANT and RESOURCE_EXCHANGE together, which is the
|
||||
* whole point of the composition model: one person, two functions, no
|
||||
* `MerchantNpc` subclass (NPC spec §2, §26).
|
||||
*/
|
||||
export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
|
||||
{
|
||||
id: BORIN_NPC_ID,
|
||||
key: BORIN_KEY,
|
||||
name: 'Borin',
|
||||
title: 'Quartermaster of the Border Watch',
|
||||
description:
|
||||
'A broad, grey-bearded man who has outlasted three captains and every fashion of optimism. He weighs what you bring him without ceremony, and pays what it is worth.',
|
||||
locationKey: 'south-gate',
|
||||
factionKey: 'border-guard',
|
||||
portraitPath: '/images/npcs/borin.png',
|
||||
artworkPath: null,
|
||||
capabilities: [
|
||||
NpcCapability.DIALOGUE,
|
||||
NpcCapability.MERCHANT,
|
||||
NpcCapability.RESOURCE_EXCHANGE,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedDialogueNode {
|
||||
npcId: string;
|
||||
key: string;
|
||||
text: string;
|
||||
priority: number;
|
||||
conditions: GameCondition[];
|
||||
actions: DialogueAction[];
|
||||
responses: DialogueResponseContent[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borin's lines, chosen by priority (NPC spec §11).
|
||||
*
|
||||
* Three nodes, deliberately overlapping: the greeting outranks the standard
|
||||
* line but only survives the first visit, and the reputation line outranks
|
||||
* both once the Border Watch actually knows the player. Nothing in
|
||||
* `NpcService` knows any of this -- it takes the highest node whose conditions
|
||||
* hold.
|
||||
*/
|
||||
export const DIALOGUE_NODES: SeedDialogueNode[] = [
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-first-meeting',
|
||||
text: 'You have the look of someone who has been south of the gate. Most who go out there come back with nothing but ash in their boots. If you did better than that, I will weigh it and pay you fairly. Not generously. Fairly.',
|
||||
priority: 800,
|
||||
// Fires only before the visit is recorded -- `NpcService` sets `met` after
|
||||
// dialogue is resolved, so this node is unreachable from the second visit.
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'met',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-trusted',
|
||||
text: 'Back again, and still walking. The Watch has started using your name without spitting afterward — that is as close to praise as we get here. Show me what you brought.',
|
||||
priority: 500,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-default',
|
||||
text: 'Pelts, hides, raider trinkets — I take all of it, and the Watch asks no questions about where it came from. Silver for goods, and a word in the right ear if the goods are good.',
|
||||
priority: 100,
|
||||
conditions: [],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcShop {
|
||||
id: string;
|
||||
key: string;
|
||||
npcId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const NPC_SHOPS: SeedNpcShop[] = [
|
||||
{
|
||||
id: BORIN_SHOP_ID,
|
||||
key: BORIN_SHOP_KEY,
|
||||
npcId: BORIN_NPC_ID,
|
||||
name: "Quartermaster's Supplies",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedShopOffer {
|
||||
shopId: string;
|
||||
itemDefinitionId: string;
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
repeatable: boolean;
|
||||
sortOrder: number;
|
||||
conditions: GameCondition[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately thin (slice §12: "Avoid adding many new items just to populate
|
||||
* the shop"). The point of 0.8 is trade-in; the shop exists so Silver has
|
||||
* somewhere to go the moment it is earned.
|
||||
*
|
||||
* No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5
|
||||
* (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic
|
||||
* Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition`
|
||||
* rather than an `ItemDefinition`, so selling one needs an offer shape this
|
||||
* slice has no reason to build.
|
||||
*/
|
||||
export const SHOP_OFFERS: SeedShopOffer[] = [
|
||||
{
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['small-healing-potion'],
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
currencyType: 'SILVER',
|
||||
price: 30,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 2,
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcExchangeProfile {
|
||||
id: string;
|
||||
key: string;
|
||||
npcId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const NPC_EXCHANGE_PROFILES: SeedNpcExchangeProfile[] = [
|
||||
{
|
||||
id: BORIN_EXCHANGE_PROFILE_ID,
|
||||
key: BORIN_EXCHANGE_KEY,
|
||||
npcId: BORIN_NPC_ID,
|
||||
name: 'Border Watch Trade-In',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedExchangeRule {
|
||||
profileId: string;
|
||||
inputItemId: string;
|
||||
inputQuantity: number;
|
||||
factionId: string;
|
||||
silverReward: number;
|
||||
regionReputationReward: number;
|
||||
renownMilestoneKey: string | null;
|
||||
conditions: GameCondition[];
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Borin pays (Playable Slice 0.8 §4, §5).
|
||||
*
|
||||
* All four Burned Road trade goods are accepted, and the rare Charred Raider
|
||||
* Insignia is worth visibly more than the common Ashen Pelt -- 30 silver
|
||||
* against 5, six times the value for a drop that comes off a 2%-weight
|
||||
* encounter (slice §5).
|
||||
*
|
||||
* Silver is up from the old turn-in values (4 and 12) because this is now the
|
||||
* only way to earn it: normal kills pay nothing at all (slice 0.7 §7), so the
|
||||
* exchange has to carry the whole economy on its own (slice §7).
|
||||
*
|
||||
* Every rule pays Border Watch reputation, the Ashen Fields faction. Only the
|
||||
* pelt carries the renown milestone: it is the guaranteed Ash Rat drop, so
|
||||
* the very first trade any player makes will fire it, whatever they hunted.
|
||||
* Provisional balancing values (slice §4).
|
||||
*/
|
||||
export const EXCHANGE_RULES: SeedExchangeRule[] = [
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['ash-pelt'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 5,
|
||||
regionReputationReward: 2,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['tough-hide'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 8,
|
||||
regionReputationReward: 3,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 2,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['bandit-insignia'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 14,
|
||||
regionReputationReward: 5,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 3,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['charred-raider-insignia'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 30,
|
||||
regionReputationReward: 12,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 4,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ITEM_IDS } from './item.constants';
|
||||
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
|
||||
|
||||
export interface SeedTurnInDefinition {
|
||||
id: string;
|
||||
key: string;
|
||||
itemDefinitionId: string;
|
||||
factionId: string;
|
||||
silverRewardPerItem: number;
|
||||
reputationRewardPerItem: number;
|
||||
repeatable: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Initial development values (spec §21), kept in content so they can be
|
||||
// tuned without touching TurnInService's logic (spec §42).
|
||||
export const TURN_IN_DEFINITIONS: SeedTurnInDefinition[] = [
|
||||
{
|
||||
id: '90000000-0000-4000-8000-000000000001',
|
||||
key: 'ash-pelt-border-guard',
|
||||
itemDefinitionId: ITEM_IDS['ash-pelt'],
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverRewardPerItem: 4,
|
||||
reputationRewardPerItem: 1,
|
||||
repeatable: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '90000000-0000-4000-8000-000000000002',
|
||||
key: 'bandit-insignia-border-guard',
|
||||
itemDefinitionId: ITEM_IDS['bandit-insignia'],
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverRewardPerItem: 12,
|
||||
reputationRewardPerItem: 4,
|
||||
repeatable: true,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -5,17 +5,27 @@ import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
|
||||
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
||||
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.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';
|
||||
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
@@ -88,7 +98,15 @@ function createDataSource(
|
||||
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
turnInDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
@@ -103,7 +121,16 @@ function createDataSource(
|
||||
if (entity === CharacterItem) return characterItemRepository;
|
||||
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||
if (entity === ReputationFaction) return reputationFactionRepository;
|
||||
if (entity === TurnInDefinition) return turnInDefinitionRepository;
|
||||
if (entity === RenownMilestoneDefinition)
|
||||
return renownMilestoneRepository;
|
||||
if (entity === NpcDefinition) return npcDefinitionRepository;
|
||||
if (entity === DialogueNode) return dialogueNodeRepository;
|
||||
if (entity === NpcShop) return npcShopRepository;
|
||||
if (entity === ShopOffer) return shopOfferRepository;
|
||||
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
|
||||
if (entity === ExchangeRule) return exchangeRuleRepository;
|
||||
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
|
||||
if (entity === CharacterLootBag) return characterLootBagRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -182,67 +209,90 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect.objectContaining({
|
||||
key: 'ash-rat',
|
||||
name: 'Ash Rat',
|
||||
monsterCategory: 'BEAST',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
attack: 5,
|
||||
armor: 0,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
// Pure baseline combat: no telegraph, no status effect (spec §4).
|
||||
abilities: {},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'road-bandit',
|
||||
name: 'Road Bandit',
|
||||
monsterCategory: 'HUMANOID',
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
abilities: { telegraph: { roundInterval: 3, damageMultiplier: 1.6 } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'wild-road-dog',
|
||||
name: 'Feral Road Hound',
|
||||
monsterCategory: 'BEAST',
|
||||
level: 1,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||
abilities: {
|
||||
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-looter',
|
||||
name: 'Charred Raider',
|
||||
monsterCategory: 'HUMANOID',
|
||||
level: 2,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||
// The rare encounter reuses the known telegraph on a tighter
|
||||
// cadence rather than introducing a new subsystem (spec §4).
|
||||
abilities: { telegraph: { roundInterval: 2, damageMultiplier: 1.6 } },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Every monster carries an atmosphere line for its encounter card
|
||||
// (spec §9).
|
||||
for (const row of monsterRepository.rows) {
|
||||
expect(typeof row.flavorText).toBe('string');
|
||||
}
|
||||
|
||||
// No monster carries a currency range any more: a normal kill grants no
|
||||
// Silver (spec §7).
|
||||
for (const row of monsterRepository.rows) {
|
||||
expect(row).not.toHaveProperty('silverMin');
|
||||
expect(row).not.toHaveProperty('silverMax');
|
||||
}
|
||||
|
||||
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ASH_RAT_MONSTER_ID,
|
||||
weight: 40,
|
||||
weight: 50,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: WILD_ROAD_DOG_MONSTER_ID,
|
||||
weight: 30,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ROAD_BANDIT_MONSTER_ID,
|
||||
weight: 20,
|
||||
weight: 18,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
// The rare find, at 2 out of 100 (spec §3).
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: CHARRED_LOOTER_MONSTER_ID,
|
||||
weight: 10,
|
||||
weight: 2,
|
||||
encounterType: 'RARE',
|
||||
}),
|
||||
]),
|
||||
['locationId', 'monsterId'],
|
||||
@@ -328,7 +378,8 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
}),
|
||||
);
|
||||
expect(southGate.localPointsOfInterest).toHaveLength(3);
|
||||
// Four since Slice 0.8 gave Borin a hotspot at the gate.
|
||||
expect(southGate.localPointsOfInterest).toHaveLength(4);
|
||||
// A transition location offers no hunt, so no HUNT hotspot may appear.
|
||||
expect(
|
||||
(southGate.localPointsOfInterest as { type: string }[]).some(
|
||||
@@ -415,7 +466,7 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(itemRepository.rows).toHaveLength(13);
|
||||
expect(itemRepository.rows).toHaveLength(15);
|
||||
expect(itemRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -431,35 +482,89 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'ash-pelt',
|
||||
name: 'Ash Pelt',
|
||||
name: 'Ashen Pelt',
|
||||
type: 'TRADE_GOOD',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'HIDE',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'tough-hide',
|
||||
name: 'Tough Hide',
|
||||
type: 'TRADE_GOOD',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'HIDE',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'bandit-insignia',
|
||||
name: 'Bandit Insignia',
|
||||
name: 'Raider Insignia',
|
||||
type: 'TROPHY',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-raider-insignia',
|
||||
name: 'Charred Raider Insignia',
|
||||
type: 'TROPHY',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(lootTableRepository.rows).toHaveLength(2);
|
||||
expect(lootEntryRepository.rows).toHaveLength(6);
|
||||
// Equipment and consumables stay outside the carrying system entirely
|
||||
// (0.7.5 §8), so they must not pick up a category by accident.
|
||||
for (const row of itemRepository.rows) {
|
||||
if (row.type === 'EQUIPMENT' || row.type === 'CONSUMABLE') {
|
||||
expect(row.lootCategory).toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// One table per monster now that each has its own guaranteed trade good.
|
||||
expect(lootTableRepository.rows).toHaveLength(4);
|
||||
expect(lootEntryRepository.rows).toHaveLength(12);
|
||||
|
||||
// Every Burned Road enemy guarantees exactly one trade good (spec §5).
|
||||
const guaranteedTradeGoods: Array<[string, string]> = [
|
||||
[ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt']],
|
||||
[WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide']],
|
||||
[ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia']],
|
||||
[CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS['charred-raider-insignia']],
|
||||
];
|
||||
for (const [lootTableId, itemDefinitionId] of guaranteedTradeGoods) {
|
||||
expect(lootEntryRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
lootTableId,
|
||||
itemDefinitionId,
|
||||
dropChance: '1.0000',
|
||||
minQuantity: 1,
|
||||
maxQuantity: 1,
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Equipment stays a roll, so it can never be confused with the
|
||||
// guaranteed good (spec §6).
|
||||
expect(lootEntryRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['ash-pelt'],
|
||||
position: 1,
|
||||
dropChance: '0.6000',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||
position: 1,
|
||||
dropChance: '0.1800',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['ash-boots'],
|
||||
dropChance: '0.0800',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['reinforced-leather-jacket'],
|
||||
dropChance: '0.1000',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -476,14 +581,100 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
key: 'ash-rat',
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'wild-road-dog',
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'road-bandit',
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-looter',
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds both starter loot bags idempotently', async () => {
|
||||
const lootBagDefinitionRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
lootBagDefinitionRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(lootBagDefinitionRepository.rows).toHaveLength(2);
|
||||
expect(lootBagDefinitionRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
lootCategory: 'HIDE',
|
||||
capacity: 5,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
capacity: 5,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the demo character one active bag per category, idempotently', async () => {
|
||||
const characterLootBagRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterLootBagRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
// Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with
|
||||
// no merchant yet a bagless character could never empty a full bag. See
|
||||
// the ASSUMPTION note in the seed.
|
||||
expect(characterLootBagRepository.rows).toHaveLength(2);
|
||||
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(
|
||||
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
|
||||
).toEqual([
|
||||
'a0000000-0000-4000-8000-000000000001',
|
||||
'a0000000-0000-4000-8000-000000000002',
|
||||
]);
|
||||
});
|
||||
|
||||
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
@@ -631,8 +822,10 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect(faction).toMatchObject({ name: 'Border Watch', enabled: true });
|
||||
});
|
||||
|
||||
it('seeds both turn-in definitions', async () => {
|
||||
const turnInDefinitionRepository = new InMemoryRepository();
|
||||
it('seeds Borin with his shop and every trade-in rule', async () => {
|
||||
const npcDefinitionRepository = new InMemoryRepository();
|
||||
const npcShopRepository = new InMemoryRepository();
|
||||
const exchangeRuleRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
@@ -645,13 +838,70 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
turnInDefinitionRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
npcDefinitionRepository,
|
||||
new InMemoryRepository(),
|
||||
npcShopRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
exchangeRuleRepository,
|
||||
);
|
||||
|
||||
// Twice: seeds must be idempotent (NPC spec §32).
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(npcDefinitionRepository.rows).toHaveLength(1);
|
||||
expect(npcDefinitionRepository.rows[0]).toMatchObject({
|
||||
key: 'borin-quartermaster',
|
||||
enabled: true,
|
||||
});
|
||||
// Placed in Graufurt via the location key, not a hardcoded id.
|
||||
expect(npcDefinitionRepository.rows[0].locationId).toBe(SOUTH_GATE_ID);
|
||||
expect(npcShopRepository.rows).toHaveLength(1);
|
||||
|
||||
// All four Burned Road trade goods are accepted (slice 0.8 §5), and the
|
||||
// rare Charred Raider Insignia pays visibly more than the common pelt.
|
||||
expect(exchangeRuleRepository.rows).toHaveLength(4);
|
||||
const silverByItem = new Map(
|
||||
exchangeRuleRepository.rows.map((row: Row) => [
|
||||
row.inputItemId,
|
||||
row.silverReward,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
silverByItem.get(ITEM_IDS['charred-raider-insignia']),
|
||||
).toBeGreaterThan(silverByItem.get(ITEM_IDS['ash-pelt']));
|
||||
});
|
||||
|
||||
it('seeds the renown milestone the first trade-in completes', async () => {
|
||||
const renownMilestoneRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
renownMilestoneRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(
|
||||
turnInDefinitionRepository.rows.map((row: Row) => row.key).sort(),
|
||||
).toEqual(['ash-pelt-border-guard', 'bandit-insignia-border-guard']);
|
||||
// Non-repeatable, so routine trading cannot pump a power rank
|
||||
// (Slice 0.6.5 §2.2).
|
||||
expect(renownMilestoneRepository.rows).toHaveLength(1);
|
||||
expect(renownMilestoneRepository.rows[0]).toMatchObject({
|
||||
key: 'first-goods-returned',
|
||||
repeatable: false,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,22 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
|
||||
import { MonsterCategory } from '../../monsters/monster-category.enum';
|
||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
|
||||
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
||||
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.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';
|
||||
import {
|
||||
ITEM_DEFINITIONS,
|
||||
LOOT_TABLES,
|
||||
@@ -25,15 +34,30 @@ import {
|
||||
} from './item-content';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import {
|
||||
BURNED_ROAD_LOCAL_CONTENT,
|
||||
SOUTH_GATE_LOCAL_CONTENT,
|
||||
} from './local-location.content';
|
||||
import {
|
||||
BASIC_HIDE_BAG_ID,
|
||||
BASIC_TROPHY_POUCH_ID,
|
||||
LOOT_BAG_DEFINITIONS,
|
||||
} from './loot-bag-content';
|
||||
import { REPUTATION_FACTIONS } from './reputation-content';
|
||||
import { TURN_IN_DEFINITIONS } from './turn-in-content';
|
||||
import {
|
||||
DIALOGUE_NODES,
|
||||
EXCHANGE_RULES,
|
||||
NPC_DEFINITIONS,
|
||||
NPC_EXCHANGE_PROFILES,
|
||||
NPC_SHOPS,
|
||||
RENOWN_MILESTONES,
|
||||
SHOP_OFFERS,
|
||||
} from './npc-content';
|
||||
import {
|
||||
ASH_RAT_MONSTER_ID,
|
||||
BURNED_ROAD_ID,
|
||||
@@ -54,9 +78,20 @@ export async function seedVisibleVerticalSlice(
|
||||
const itemRepository = dataSource.getRepository(ItemDefinition);
|
||||
const lootTableRepository = dataSource.getRepository(LootTable);
|
||||
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
||||
const lootBagDefinitionRepository =
|
||||
dataSource.getRepository(LootBagDefinition);
|
||||
const reputationFactionRepository =
|
||||
dataSource.getRepository(ReputationFaction);
|
||||
const turnInDefinitionRepository = dataSource.getRepository(TurnInDefinition);
|
||||
const renownMilestoneRepository = dataSource.getRepository(
|
||||
RenownMilestoneDefinition,
|
||||
);
|
||||
const npcDefinitionRepository = dataSource.getRepository(NpcDefinition);
|
||||
const dialogueNodeRepository = dataSource.getRepository(DialogueNode);
|
||||
const npcShopRepository = dataSource.getRepository(NpcShop);
|
||||
const shopOfferRepository = dataSource.getRepository(ShopOffer);
|
||||
const npcExchangeProfileRepository =
|
||||
dataSource.getRepository(NpcExchangeProfile);
|
||||
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
|
||||
|
||||
const locations = [
|
||||
{
|
||||
@@ -136,20 +171,27 @@ export async function seedVisibleVerticalSlice(
|
||||
'lootTableId',
|
||||
'itemDefinitionId',
|
||||
]);
|
||||
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
|
||||
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
|
||||
await turnInDefinitionRepository.upsert(TURN_IN_DEFINITIONS, ['key']);
|
||||
await renownMilestoneRepository.upsert(RENOWN_MILESTONES, ['key']);
|
||||
|
||||
// Burned Road roster (Playable Slice 0.7 V2 §2, §4). Each enemy has to be
|
||||
// mechanically and economically distinct, so the differences live in
|
||||
// `abilities` (what it does in a fight) and `lootTableId` (what it is worth)
|
||||
// rather than in engine or UI branches.
|
||||
const monsters = [
|
||||
{
|
||||
id: ASH_RAT_MONSTER_ID,
|
||||
key: 'ash-rat',
|
||||
name: 'Ash Rat',
|
||||
monsterCategory: MonsterCategory.BEAST,
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
attack: 5,
|
||||
armor: 0,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
|
||||
// Pure baseline combat: no telegraph, no status effect, short fight.
|
||||
abilities: {},
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
iconPath: '/images/combat/icons/ash-rat-128.png',
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
@@ -158,28 +200,39 @@ export async function seedVisibleVerticalSlice(
|
||||
id: WILD_ROAD_DOG_MONSTER_ID,
|
||||
key: 'wild-road-dog',
|
||||
name: 'Feral Road Hound',
|
||||
monsterCategory: MonsterCategory.BEAST,
|
||||
level: 1,
|
||||
maxHp: 55,
|
||||
attack: 7,
|
||||
armor: 0,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
flavorText:
|
||||
'It stopped waiting for scraps a long time ago. Its bites do not close.',
|
||||
// Introduces Bleeding: every third round its bite also opens a wound
|
||||
// that ticks for two rounds. Windows of clean rounds in between keep
|
||||
// the effect readable instead of permanent.
|
||||
abilities: {
|
||||
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
||||
},
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||
// Shares the beast table: both are scorched road animals that leave a
|
||||
// pelt behind. A table of its own waits for content that differs.
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
},
|
||||
{
|
||||
id: ROAD_BANDIT_MONSTER_ID,
|
||||
key: 'road-bandit',
|
||||
name: 'Road Bandit',
|
||||
monsterCategory: MonsterCategory.HUMANOID,
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
flavorText:
|
||||
'This one has done it before, and winds up the swing where you can see it.',
|
||||
// Reinforces telegraphing: winds up a Heavy Strike every third round,
|
||||
// which Shield Bash can interrupt and Defend can blunt.
|
||||
abilities: {
|
||||
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
||||
},
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
iconPath: '/images/combat/icons/road-bandit-128.png',
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
@@ -188,16 +241,22 @@ export async function seedVisibleVerticalSlice(
|
||||
id: CHARRED_LOOTER_MONSTER_ID,
|
||||
key: 'charred-looter',
|
||||
name: 'Charred Raider',
|
||||
monsterCategory: MonsterCategory.HUMANOID,
|
||||
level: 2,
|
||||
maxHp: 85,
|
||||
attack: 11,
|
||||
armor: 6,
|
||||
silverMin: 0,
|
||||
silverMax: 0,
|
||||
flavorText:
|
||||
'Burned armor, a burned face, and no interest at all in your reasons.',
|
||||
// The rare encounter: no new subsystem, just the known telegraph on a
|
||||
// tighter cadence on top of higher stats. It is winding up almost
|
||||
// constantly, so the player has to interrupt or brace.
|
||||
abilities: {
|
||||
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
||||
},
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||
// Shares the raider table: same gear, taken from the same caravans.
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -217,22 +276,30 @@ export async function seedVisibleVerticalSlice(
|
||||
monsterIds.set(key, existingMonster?.id ?? id);
|
||||
}
|
||||
|
||||
// Weights read as "how often you meet this on the road". They also drive the
|
||||
// location's danger rating, which is computed from the weighted average of
|
||||
// the pool rather than from its single worst entry.
|
||||
const encounterWeights: Readonly<Record<string, number>> = {
|
||||
'ash-rat': 40,
|
||||
'wild-road-dog': 30,
|
||||
'road-bandit': 20,
|
||||
'charred-looter': 10,
|
||||
};
|
||||
// Weights read as "how often you meet this on the road" (spec §3). They also
|
||||
// drive the location's danger rating, which is computed from the weighted
|
||||
// average of the pool rather than from its single worst entry.
|
||||
//
|
||||
// The Charred Raider is deliberately the odd one out at weight 2: it is a
|
||||
// rare find, and `EncounterType.RARE` is what the hunt card reads to mark it
|
||||
// as such.
|
||||
const encounterPool: ReadonlyArray<{
|
||||
key: string;
|
||||
weight: number;
|
||||
encounterType: EncounterType;
|
||||
}> = [
|
||||
{ key: 'ash-rat', weight: 50, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'wild-road-dog', weight: 30, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'road-bandit', weight: 18, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'charred-looter', weight: 2, encounterType: EncounterType.RARE },
|
||||
];
|
||||
|
||||
await locationMonsterRepository.upsert(
|
||||
Object.entries(encounterWeights).map(([key, weight]) => ({
|
||||
encounterPool.map(({ key, weight, encounterType }) => ({
|
||||
locationId: burnedRoadId,
|
||||
monsterId: monsterIds.get(key) as string,
|
||||
weight,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
encounterType,
|
||||
enabled: true,
|
||||
})),
|
||||
['locationId', 'monsterId'],
|
||||
@@ -294,4 +361,62 @@ export async function seedVisibleVerticalSlice(
|
||||
characterItemId: startingSwordItemId,
|
||||
});
|
||||
}
|
||||
|
||||
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant
|
||||
// and quest slices, so nothing in 0.7.5 hands out a bag).
|
||||
//
|
||||
// The demo character starts with both starter bags anyway. Without them the
|
||||
// bagless default of 1 applies, and since the merchant that empties a bag
|
||||
// only arrives in 0.8, the second kill of a hunt would leave its trade good
|
||||
// behind forever -- the hunting loop would be unplayable between these two
|
||||
// slices. Capacity 5 lets the fill-up actually be experienced.
|
||||
//
|
||||
// Smallest reversible choice: two seed rows, no acquisition mechanism.
|
||||
// Delete them once the merchant sells bags.
|
||||
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
|
||||
for (const lootBagDefinitionId of [
|
||||
BASIC_HIDE_BAG_ID,
|
||||
BASIC_TROPHY_POUCH_ID,
|
||||
]) {
|
||||
const existingBag = await characterLootBagRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId,
|
||||
});
|
||||
if (!existingBag) {
|
||||
await characterLootBagRepository.insert({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
|
||||
//
|
||||
// Seeded after the character so a fresh database has somewhere to stand and
|
||||
// someone to trade with in one pass. Every link uses a stable business key
|
||||
// rather than a generated id (spec §4), and every write is an upsert, so
|
||||
// re-running the seed re-tunes prices without duplicating Borin.
|
||||
for (const npc of NPC_DEFINITIONS) {
|
||||
const locationId = locationIds.get(npc.locationKey);
|
||||
if (!locationId) {
|
||||
throw new Error(
|
||||
`NPC ${npc.key} references unknown location ${npc.locationKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { locationKey, ...definition } = npc;
|
||||
await npcDefinitionRepository.upsert({ ...definition, locationId }, [
|
||||
'key',
|
||||
]);
|
||||
}
|
||||
|
||||
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
|
||||
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
|
||||
await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']);
|
||||
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
|
||||
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
|
||||
'profileId',
|
||||
'inputItemId',
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user