diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts new file mode 100644 index 0000000..8ac04ac --- /dev/null +++ b/apps/api/src/database/seeds/item-content.ts @@ -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> = {}, +): 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'), +]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts new file mode 100644 index 0000000..7a614ef --- /dev/null +++ b/apps/api/src/database/seeds/item.constants.ts @@ -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'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 39e0c44..c24015f 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -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; @@ -67,24 +71,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'); }), @@ -256,4 +256,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 }), + ]), + ); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index d0ca3b8..92add28 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -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 { + ASH_RAT_LOOT_TABLE_ID, + ROAD_BANDIT_LOOT_TABLE_ID, +} from './item.constants'; +import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; import { ASH_RAT_MONSTER_ID, BURNED_ROAD_ID, @@ -21,6 +29,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 = [ { @@ -95,6 +106,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, @@ -108,6 +128,7 @@ export async function seedVisibleVerticalSlice( silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', + lootTableId: ASH_RAT_LOOT_TABLE_ID, }, { id: ROAD_BANDIT_MONSTER_ID, @@ -121,6 +142,7 @@ export async function seedVisibleVerticalSlice( silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, }, ]; let ashRatId = ASH_RAT_MONSTER_ID; @@ -176,6 +198,7 @@ export async function seedVisibleVerticalSlice( name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100,