Merge branch 'master' into worktree-local-location-view
Brings in the First Loot slice. Resolved additively: - MonsterDefinition keeps both the new iconPath and master's lootTableId. - The seed keeps the four-monster pool and the local view content, and gives the two new monsters existing loot tables — the road dog shares the beast table, the charred looter the raider table. - The local location view migration moves to 1788700000000 so it orders deterministically after the loot migration, which claimed the same timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateLootAndRewards1788600000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Existing characters keep their progression; silver simply starts at 0.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')",
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "item_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"type" "item_type_enum" NOT NULL,
|
||||
"equipment_slot" "equipment_slot_enum",
|
||||
"rarity" "item_rarity_enum" NOT NULL,
|
||||
"tier" integer NOT NULL,
|
||||
"required_level" integer NOT NULL,
|
||||
"weapon_damage" integer NOT NULL DEFAULT 0,
|
||||
"bonus_hp" integer NOT NULL DEFAULT 0,
|
||||
"bonus_attack" integer NOT NULL DEFAULT 0,
|
||||
"bonus_armor" integer NOT NULL DEFAULT 0,
|
||||
"sell_price" integer NOT NULL DEFAULT 0,
|
||||
"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_item_definitions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "loot_tables" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) 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_tables" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "loot_table_entries" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"loot_table_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"drop_chance" numeric(5,4) NOT NULL,
|
||||
"min_quantity" integer NOT NULL DEFAULT 1,
|
||||
"max_quantity" integer NOT NULL DEFAULT 1,
|
||||
"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_loot_table_entries" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1),
|
||||
CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"),
|
||||
CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "character_items" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"quantity" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_items" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1),
|
||||
CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combat_rewards" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"combat_id" uuid NOT NULL,
|
||||
"character_id" uuid NOT NULL,
|
||||
"experience_granted" integer NOT NULL,
|
||||
"silver_granted" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
// The database half of the "one reward per combat" invariant (spec §7).
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combat_reward_items" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"combat_reward_id" uuid NOT NULL,
|
||||
"character_item_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"quantity" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1),
|
||||
CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"');
|
||||
await queryRunner.query('DROP TABLE "combat_reward_items"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"');
|
||||
await queryRunner.query('DROP TABLE "combat_rewards"');
|
||||
await queryRunner.query('DROP INDEX "IDX_character_items_character_item"');
|
||||
await queryRunner.query('DROP TABLE "character_items"');
|
||||
await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"',
|
||||
);
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"');
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"');
|
||||
await queryRunner.query('DROP TABLE "loot_table_entries"');
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
|
||||
await queryRunner.query('DROP TABLE "loot_tables"');
|
||||
await queryRunner.query('DROP INDEX "IDX_item_definitions_key"');
|
||||
await queryRunner.query('DROP TABLE "item_definitions"');
|
||||
await queryRunner.query('DROP TYPE "item_rarity_enum"');
|
||||
await queryRunner.query('DROP TYPE "equipment_slot_enum"');
|
||||
await queryRunner.query('DROP TYPE "item_type_enum"');
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
* alone — the map and hunt screens still render them. Backfill defaults keep
|
||||
* existing rows valid; the seed replaces them with authored content.
|
||||
*/
|
||||
export class CreateLocalLocationView1788600000000
|
||||
export class CreateLocalLocationView1788700000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
@@ -6,7 +6,7 @@ import { MonsterDefinition } from '../../monsters/entities/monster-definition.en
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
|
||||
const MIGRATION_SQL = readFileSync(
|
||||
join(__dirname, '1788600000000-CreateLocalLocationView.ts'),
|
||||
join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
|
||||
|
||||
function uniqueIndexFor(target: unknown, columns: string[]) {
|
||||
const index = getMetadataArgsStorage().indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === target &&
|
||||
columns.every((column) => candidate.columns?.includes(column)),
|
||||
);
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
return indexMetadata?.options?.unique ?? indexMetadata?.unique;
|
||||
}
|
||||
|
||||
describe('loot and rewards schema', () => {
|
||||
it('gives every combat at most one reward record', () => {
|
||||
expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps one stack per character per item definition', () => {
|
||||
expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps loot-table content keys and entry positions unique', () => {
|
||||
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('maps reward and loot relations with the documented onDelete behavior', () => {
|
||||
const relations = getMetadataArgsStorage().relations.filter((relation) =>
|
||||
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes(
|
||||
relation.target as never,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
relations.map((relation) => ({
|
||||
onDelete: relation.options.onDelete,
|
||||
propertyName: relation.propertyName,
|
||||
target: relation.target,
|
||||
})),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the character silver column and the nullable monster loot table link', () => {
|
||||
const columns = getMetadataArgsStorage().columns;
|
||||
|
||||
const silver = columns.find(
|
||||
(candidate) => candidate.target === Character && candidate.propertyName === 'silver',
|
||||
);
|
||||
expect(silver).toBeDefined();
|
||||
expect(silver?.options.type).toBe('integer');
|
||||
|
||||
const lootTableId = columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId',
|
||||
);
|
||||
expect(lootTableId).toBeDefined();
|
||||
expect(lootTableId?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('stores drop chance as a numeric column so probabilities stay data-driven', () => {
|
||||
const dropChance = getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance',
|
||||
);
|
||||
|
||||
expect(dropChance?.options.type).toBe('numeric');
|
||||
expect(dropChance?.options.precision).toBe(5);
|
||||
expect(dropChance?.options.scale).toBe(4);
|
||||
});
|
||||
|
||||
it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => {
|
||||
// synchronize: false means entity decorators never touch the real database -
|
||||
// only the raw SQL emitted by the migration itself does. Assert on that SQL
|
||||
// directly so deleting a constraint here would fail this test.
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateLootAndRewards1788600000000();
|
||||
|
||||
await migration.up(queryRunner);
|
||||
|
||||
const upQueries = query.mock.calls.map(([sql]) => sql as string);
|
||||
|
||||
expect(upQueries).toEqual(
|
||||
expect.arrayContaining([
|
||||
// The database half of the "one reward per combat" invariant (spec §7, §37).
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'),
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'),
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'),
|
||||
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'),
|
||||
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'),
|
||||
]),
|
||||
);
|
||||
|
||||
const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK ('));
|
||||
expect(checkConstraints.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
checkConstraints.some(
|
||||
(sql) =>
|
||||
sql.includes('CHK_loot_table_entries_drop_chance') ||
|
||||
sql.includes('CHK_character_items_quantity'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await migration.down(queryRunner);
|
||||
|
||||
const downQueries = query.mock.calls
|
||||
.slice(upQueries.length)
|
||||
.map(([sql]) => sql as string);
|
||||
|
||||
// Proves down() is real and reverses the up() migration, not a no-op.
|
||||
expect(downQueries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('DROP TABLE "combat_rewards"'),
|
||||
expect.stringContaining('DROP TABLE "character_items"'),
|
||||
'ALTER TABLE "characters" DROP COLUMN "silver"',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
216
apps/api/src/database/seeds/item-content.ts
Normal file
216
apps/api/src/database/seeds/item-content.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../../items/item-rarity.enum';
|
||||
import { ItemType } from '../../items/item-type.enum';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ItemKey,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
|
||||
export interface SeedItemDefinition {
|
||||
id: string;
|
||||
key: ItemKey;
|
||||
name: string;
|
||||
description: string;
|
||||
type: ItemType;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
rarity: ItemRarity;
|
||||
tier: number;
|
||||
requiredLevel: number;
|
||||
weaponDamage: number;
|
||||
bonusHp: number;
|
||||
bonusAttack: number;
|
||||
bonusArmor: number;
|
||||
sellPrice: number;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
function item(
|
||||
key: ItemKey,
|
||||
name: string,
|
||||
description: string,
|
||||
type: ItemType,
|
||||
equipmentSlot: EquipmentSlot | null,
|
||||
rarity: ItemRarity,
|
||||
stats: Partial<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
|
||||
): SeedItemDefinition {
|
||||
return {
|
||||
id: ITEM_IDS[key],
|
||||
key,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
equipmentSlot,
|
||||
rarity,
|
||||
tier: 1,
|
||||
requiredLevel: 1,
|
||||
weaponDamage: stats.weaponDamage ?? 0,
|
||||
bonusHp: stats.bonusHp ?? 0,
|
||||
bonusAttack: stats.bonusAttack ?? 0,
|
||||
bonusArmor: stats.bonusArmor ?? 0,
|
||||
// Always 0: no merchants exist in Slice 0.4, and the balancing doc's
|
||||
// Grenzmarken table lists purchase prices, not sell prices.
|
||||
sellPrice: 0,
|
||||
iconPath: `/images/items/${key}.png`,
|
||||
};
|
||||
}
|
||||
|
||||
// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19.
|
||||
export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
|
||||
item(
|
||||
'worn-short-sword',
|
||||
'Abgenutztes Kurzschwert',
|
||||
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 8 },
|
||||
),
|
||||
item(
|
||||
'bandit-blade',
|
||||
'Räuberklinge',
|
||||
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 11, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'ash-blade',
|
||||
'Aschenklinge',
|
||||
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.RARE,
|
||||
{ weaponDamage: 15, bonusAttack: 2 },
|
||||
),
|
||||
item(
|
||||
'bandit-hood',
|
||||
'Räuberhaube',
|
||||
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.HEAD,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'reinforced-leather-jacket',
|
||||
'Verstärkte Lederjacke',
|
||||
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.CHEST,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 7, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'raider-gloves',
|
||||
'Plündererhandschuhe',
|
||||
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.HANDS,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'guardsman-legs',
|
||||
'Wachmannsbeinkleid',
|
||||
'Beinzeug der Grenzwacht, an den Knien geflickt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.LEGS,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 5, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'ash-boots',
|
||||
'Aschenstiefel',
|
||||
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.FEET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 4, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'borderwatch-sigil',
|
||||
'Zeichen der Grenzwacht',
|
||||
'Das Wappen eines Turms, den es nicht mehr gibt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusAttack: 3, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'burned-captain-pendant',
|
||||
'Anhänger des verbrannten Hauptmanns',
|
||||
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.EPIC,
|
||||
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
|
||||
),
|
||||
// Seeded as content only. Slice 0.4 implements no consumable use, and the
|
||||
// Straßenräuber loot entry for it is deliberately deferred (spec §16).
|
||||
item(
|
||||
'small-healing-potion',
|
||||
'Kleiner Heiltrank',
|
||||
'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.',
|
||||
ItemType.CONSUMABLE,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
item(
|
||||
'ash-pelt',
|
||||
'Aschenfell',
|
||||
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
|
||||
ItemType.MATERIAL,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
];
|
||||
|
||||
export const LOOT_TABLES = [
|
||||
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' },
|
||||
{ id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' },
|
||||
];
|
||||
|
||||
export interface SeedLootTableEntry {
|
||||
lootTableId: string;
|
||||
itemDefinitionId: string;
|
||||
position: number;
|
||||
dropChance: string;
|
||||
minQuantity: number;
|
||||
maxQuantity: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function entry(
|
||||
lootTableId: string,
|
||||
key: ItemKey,
|
||||
position: number,
|
||||
dropChance: string,
|
||||
): SeedLootTableEntry {
|
||||
return {
|
||||
lootTableId,
|
||||
itemDefinitionId: ITEM_IDS[key],
|
||||
position,
|
||||
dropChance,
|
||||
minQuantity: 1,
|
||||
maxQuantity: 1,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is
|
||||
* omitted here because Slice 0.4 implements no consumables (spec §16).
|
||||
*/
|
||||
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
|
||||
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'),
|
||||
];
|
||||
20
apps/api/src/database/seeds/item.constants.ts
Normal file
20
apps/api/src/database/seeds/item.constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Stable content ids, in art-sheet order. Aschenfell (no sheet entry) is last.
|
||||
export const ITEM_IDS = {
|
||||
'worn-short-sword': '50000000-0000-4000-8000-000000000001',
|
||||
'bandit-blade': '50000000-0000-4000-8000-000000000002',
|
||||
'ash-blade': '50000000-0000-4000-8000-000000000003',
|
||||
'bandit-hood': '50000000-0000-4000-8000-000000000004',
|
||||
'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005',
|
||||
'raider-gloves': '50000000-0000-4000-8000-000000000006',
|
||||
'guardsman-legs': '50000000-0000-4000-8000-000000000007',
|
||||
'ash-boots': '50000000-0000-4000-8000-000000000008',
|
||||
'borderwatch-sigil': '50000000-0000-4000-8000-000000000009',
|
||||
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
|
||||
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
|
||||
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
|
||||
} as const;
|
||||
|
||||
export type ItemKey = keyof typeof ITEM_IDS;
|
||||
|
||||
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
|
||||
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
|
||||
@@ -1,9 +1,13 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.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 { 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 { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -69,24 +73,20 @@ function createDataSource(
|
||||
characterRepository: InMemoryRepository,
|
||||
monsterRepository: InMemoryRepository,
|
||||
locationMonsterRepository: InMemoryRepository,
|
||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === LocationDefinition) {
|
||||
return locationRepository;
|
||||
}
|
||||
if (entity === LocationConnection) {
|
||||
return connectionRepository;
|
||||
}
|
||||
if (entity === Character) {
|
||||
return characterRepository;
|
||||
}
|
||||
if (entity === MonsterDefinition) {
|
||||
return monsterRepository;
|
||||
}
|
||||
if (entity === LocationMonster) {
|
||||
return locationMonsterRepository;
|
||||
}
|
||||
if (entity === LocationDefinition) return locationRepository;
|
||||
if (entity === LocationConnection) return connectionRepository;
|
||||
if (entity === Character) return characterRepository;
|
||||
if (entity === MonsterDefinition) return monsterRepository;
|
||||
if (entity === LocationMonster) return locationMonsterRepository;
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -369,4 +369,84 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const itemRepository = new InMemoryRepository();
|
||||
const lootTableRepository = new InMemoryRepository();
|
||||
const lootEntryRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
itemRepository,
|
||||
lootTableRepository,
|
||||
lootEntryRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(itemRepository.rows).toHaveLength(12);
|
||||
expect(itemRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
type: 'WEAPON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
rarity: 'COMMON',
|
||||
weaponDamage: 11,
|
||||
bonusAttack: 1,
|
||||
sellPrice: 0,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'ash-pelt',
|
||||
name: 'Aschenfell',
|
||||
type: 'MATERIAL',
|
||||
equipmentSlot: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(lootTableRepository.rows).toHaveLength(2);
|
||||
expect(lootEntryRepository.rows).toHaveLength(5);
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// The Kleiner Heiltrank entry is deliberately deferred (spec §16).
|
||||
expect(
|
||||
lootEntryRepository.rows.some(
|
||||
(row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'],
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(monsterRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }),
|
||||
expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
||||
import { Character } from '../../characters/entities/character.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 { EncounterType } from '../../monsters/entities/encounter-type.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 { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import {
|
||||
BURNED_ROAD_LOCAL_CONTENT,
|
||||
SOUTH_GATE_LOCAL_CONTENT,
|
||||
@@ -27,6 +35,9 @@ export async function seedVisibleVerticalSlice(
|
||||
const characterRepository = dataSource.getRepository(Character);
|
||||
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
||||
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
||||
const itemRepository = dataSource.getRepository(ItemDefinition);
|
||||
const lootTableRepository = dataSource.getRepository(LootTable);
|
||||
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
||||
|
||||
const locations = [
|
||||
{
|
||||
@@ -98,6 +109,15 @@ export async function seedVisibleVerticalSlice(
|
||||
['fromLocationId', 'toLocationId'],
|
||||
);
|
||||
|
||||
// Content is upserted by its stable key so re-running never duplicates rows
|
||||
// and never touches player-owned character_items or combat_rewards.
|
||||
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
|
||||
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
|
||||
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
|
||||
'lootTableId',
|
||||
'itemDefinitionId',
|
||||
]);
|
||||
|
||||
const monsters = [
|
||||
{
|
||||
id: ASH_RAT_MONSTER_ID,
|
||||
@@ -112,6 +132,7 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMax: 7,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
iconPath: '/images/monsters/icons/ash-rat-128.png',
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
},
|
||||
{
|
||||
id: WILD_ROAD_DOG_MONSTER_ID,
|
||||
@@ -126,6 +147,9 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMax: 9,
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/monsters/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,
|
||||
},
|
||||
{
|
||||
id: ROAD_BANDIT_MONSTER_ID,
|
||||
@@ -140,6 +164,7 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
iconPath: '/images/monsters/icons/road-bandit-128.png',
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
},
|
||||
{
|
||||
id: CHARRED_LOOTER_MONSTER_ID,
|
||||
@@ -154,6 +179,8 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMax: 19,
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/monsters/icons/charred-looter-128.png',
|
||||
// Shares the raider table: same gear, taken from the same caravans.
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -204,6 +231,7 @@ export async function seedVisibleVerticalSlice(
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
|
||||
Reference in New Issue
Block a user