feat(loot): seed tier-1 items and the ash rat and road bandit loot tables

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-19 23:59:08 +02:00
parent 35559e3d2b
commit c58a46b7d9
4 changed files with 354 additions and 15 deletions

View 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 §2728.
* 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'),
];

View 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';

View File

@@ -1,9 +1,13 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity'; 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 { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.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'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@@ -67,24 +71,20 @@ function createDataSource(
characterRepository: InMemoryRepository, characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository, monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository, locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource { ): DataSource {
return { return {
getRepository: jest.fn((entity: unknown) => { getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) { if (entity === LocationDefinition) return locationRepository;
return locationRepository; if (entity === LocationConnection) return connectionRepository;
} if (entity === Character) return characterRepository;
if (entity === LocationConnection) { if (entity === MonsterDefinition) return monsterRepository;
return connectionRepository; if (entity === LocationMonster) return locationMonsterRepository;
} if (entity === ItemDefinition) return itemRepository;
if (entity === Character) { if (entity === LootTable) return lootTableRepository;
return characterRepository; if (entity === LootTableEntry) return lootEntryRepository;
}
if (entity === MonsterDefinition) {
return monsterRepository;
}
if (entity === LocationMonster) {
return locationMonsterRepository;
}
throw new Error('Unexpected repository'); 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 }),
]),
);
});
}); });

View File

@@ -1,11 +1,19 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity'; 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 { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.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 { import {
ASH_RAT_MONSTER_ID, ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
@@ -21,6 +29,9 @@ export async function seedVisibleVerticalSlice(
const characterRepository = dataSource.getRepository(Character); const characterRepository = dataSource.getRepository(Character);
const monsterRepository = dataSource.getRepository(MonsterDefinition); const monsterRepository = dataSource.getRepository(MonsterDefinition);
const locationMonsterRepository = dataSource.getRepository(LocationMonster); const locationMonsterRepository = dataSource.getRepository(LocationMonster);
const itemRepository = dataSource.getRepository(ItemDefinition);
const lootTableRepository = dataSource.getRepository(LootTable);
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
const locations = [ const locations = [
{ {
@@ -95,6 +106,15 @@ export async function seedVisibleVerticalSlice(
['fromLocationId', 'toLocationId'], ['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 = [ const monsters = [
{ {
id: ASH_RAT_MONSTER_ID, id: ASH_RAT_MONSTER_ID,
@@ -108,6 +128,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 4, silverMin: 4,
silverMax: 7, silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
}, },
{ {
id: ROAD_BANDIT_MONSTER_ID, id: ROAD_BANDIT_MONSTER_ID,
@@ -121,6 +142,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 9, silverMin: 9,
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}, },
]; ];
let ashRatId = ASH_RAT_MONSTER_ID; let ashRatId = ASH_RAT_MONSTER_ID;
@@ -176,6 +198,7 @@ export async function seedVisibleVerticalSlice(
name: 'Aric Duskwalker', name: 'Aric Duskwalker',
level: 1, level: 1,
experience: 0, experience: 0,
silver: 0,
baseHp: 100, baseHp: 100,
baseAttack: 6, baseAttack: 6,
currentHp: 100, currentHp: 100,