feat(content): migrate ItemType, seed Räuberabzeichen/Grenzwacht/turn-ins, zero direct monster rewards

This commit is contained in:
Bastian Wagner
2026-08-21 13:49:49 +02:00
parent 413fafd574
commit dce26e00ad
6 changed files with 208 additions and 47 deletions

View File

@@ -17,7 +17,6 @@ export interface SeedItemDefinition {
equipmentSlot: EquipmentSlot | null;
rarity: ItemRarity;
tier: number;
requiredLevel: number;
weaponDamage: number;
bonusHp: number;
bonusAttack: number;
@@ -33,7 +32,12 @@ function item(
type: ItemType,
equipmentSlot: EquipmentSlot | null,
rarity: ItemRarity,
stats: Partial<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
stats: Partial<
Pick<
SeedItemDefinition,
'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'
>
> = {},
): SeedItemDefinition {
return {
id: ITEM_IDS[key],
@@ -44,7 +48,6 @@ function item(
equipmentSlot,
rarity,
tier: 1,
requiredLevel: 1,
weaponDamage: stats.weaponDamage ?? 0,
bonusHp: stats.bonusHp ?? 0,
bonusAttack: stats.bonusAttack ?? 0,
@@ -62,7 +65,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'worn-short-sword',
'Abgenutztes Kurzschwert',
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
ItemType.WEAPON,
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 8 },
@@ -71,7 +74,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'bandit-blade',
'Räuberklinge',
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
ItemType.WEAPON,
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 11, bonusAttack: 1 },
@@ -80,7 +83,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'ash-blade',
'Aschenklinge',
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
ItemType.WEAPON,
ItemType.EQUIPMENT,
EquipmentSlot.WEAPON,
ItemRarity.RARE,
{ weaponDamage: 15, bonusAttack: 2 },
@@ -89,7 +92,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'bandit-hood',
'Räuberhaube',
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.HEAD,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusHp: 5 },
@@ -98,7 +101,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'reinforced-leather-jacket',
'Verstärkte Lederjacke',
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.CHEST,
ItemRarity.RARE,
{ bonusArmor: 7, bonusHp: 10 },
@@ -107,7 +110,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'raider-gloves',
'Plündererhandschuhe',
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.HANDS,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusAttack: 1 },
@@ -116,7 +119,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'guardsman-legs',
'Wachmannsbeinkleid',
'Beinzeug der Grenzwacht, an den Knien geflickt.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.LEGS,
ItemRarity.RARE,
{ bonusArmor: 5, bonusHp: 5 },
@@ -125,7 +128,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'ash-boots',
'Aschenstiefel',
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.FEET,
ItemRarity.RARE,
{ bonusArmor: 4, bonusHp: 5 },
@@ -134,7 +137,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'borderwatch-sigil',
'Zeichen der Grenzwacht',
'Das Wappen eines Turms, den es nicht mehr gibt.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.AMULET,
ItemRarity.RARE,
{ bonusAttack: 3, bonusHp: 10 },
@@ -143,7 +146,7 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
'burned-captain-pendant',
'Anhänger des verbrannten Hauptmanns',
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
ItemType.ARMOR,
ItemType.EQUIPMENT,
EquipmentSlot.AMULET,
ItemRarity.EPIC,
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
@@ -158,11 +161,21 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
null,
ItemRarity.COMMON,
),
// A Trade Good (spec §17): turned in to the Grenzwacht for Silver and
// reputation rather than crafted with.
item(
'ash-pelt',
'Aschenfell',
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
ItemType.MATERIAL,
ItemType.TRADE_GOOD,
null,
ItemRarity.COMMON,
),
item(
'bandit-insignia',
'Räuberabzeichen',
'Ein grob geprägtes Zeichen, das einen Straßenräuber als Mitglied seiner Bande auswies.',
ItemType.TROPHY,
null,
ItemRarity.COMMON,
),
@@ -170,7 +183,11 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
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' },
{
id: ROAD_BANDIT_LOOT_TABLE_ID,
key: 'road-bandit-loot',
name: 'Straßenräuber Beute',
},
];
export interface SeedLootTableEntry {
@@ -213,4 +230,5 @@ export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
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'),
];

View File

@@ -12,6 +12,7 @@ export const ITEM_IDS = {
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
} as const;
export type ItemKey = keyof typeof ITEM_IDS;

View File

@@ -0,0 +1,25 @@
export const BORDER_GUARD_FACTION_ID = '80000000-0000-4000-8000-000000000001';
export interface SeedReputationFaction {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
enabled: boolean;
}
// Only Grenzwacht is seeded and usable in Slice 0.6.5 (spec §8). Dämmerjäger
// ('dusk-hunters') and Letzte Wacht ('last-watch') are future-region keys,
// intentionally not seeded yet.
export const REPUTATION_FACTIONS: SeedReputationFaction[] = [
{
id: BORDER_GUARD_FACTION_ID,
key: 'border-guard',
name: 'Grenzwacht',
description:
'Die letzten organisierten Wächter der Aschenfelder, die verbliebene Handelsrouten und Außenposten schützen.',
regionKey: 'ashen-fields',
enabled: true,
},
];

View File

@@ -0,0 +1,38 @@
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,
},
];

View File

@@ -9,8 +9,14 @@ 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 { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
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>;
@@ -81,6 +87,8 @@ function createDataSource(
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
turnInDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
@@ -94,6 +102,8 @@ function createDataSource(
if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
if (entity === ReputationFaction) return reputationFactionRepository;
if (entity === TurnInDefinition) return turnInDefinitionRepository;
throw new Error('Unexpected repository');
}),
@@ -119,7 +129,7 @@ describe('seedVisibleVerticalSlice', () => {
Object.assign(characterRepository.rows[0], {
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
renown: 1,
});
await seedVisibleVerticalSlice(dataSource);
@@ -158,7 +168,7 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({
currentLocationId: BURNED_ROAD_ID,
currentHp: 57,
experience: 39,
renown: 1,
}),
);
@@ -173,9 +183,8 @@ describe('seedVisibleVerticalSlice', () => {
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/ash-rat.png',
}),
expect.objectContaining({
@@ -185,9 +194,8 @@ describe('seedVisibleVerticalSlice', () => {
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/road-bandit.png',
}),
expect.objectContaining({
@@ -400,13 +408,13 @@ describe('seedVisibleVerticalSlice', () => {
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(itemRepository.rows).toHaveLength(12);
expect(itemRepository.rows).toHaveLength(13);
expect(itemRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'bandit-blade',
name: 'Räuberklinge',
type: 'WEAPON',
type: 'EQUIPMENT',
equipmentSlot: 'WEAPON',
rarity: 'COMMON',
weaponDamage: 11,
@@ -417,14 +425,20 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({
key: 'ash-pelt',
name: 'Aschenfell',
type: 'MATERIAL',
type: 'TRADE_GOOD',
equipmentSlot: null,
}),
expect.objectContaining({
key: 'bandit-insignia',
name: 'Räuberabzeichen',
type: 'TROPHY',
equipmentSlot: null,
}),
]),
);
expect(lootTableRepository.rows).toHaveLength(2);
expect(lootEntryRepository.rows).toHaveLength(5);
expect(lootEntryRepository.rows).toHaveLength(6);
expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -451,8 +465,14 @@ describe('seedVisibleVerticalSlice', () => {
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 }),
expect.objectContaining({
key: 'ash-rat',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
}),
expect.objectContaining({
key: 'road-bandit',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}),
]),
);
});
@@ -522,7 +542,8 @@ describe('seedVisibleVerticalSlice', () => {
await seedVisibleVerticalSlice(dataSource);
// Simulate the player having equipped earned loot instead.
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
characterEquipmentRepository.rows[0]['characterItemId'] =
'earned-bandit-blade-item-id';
await seedVisibleVerticalSlice(dataSource);
@@ -577,4 +598,53 @@ describe('seedVisibleVerticalSlice', () => {
}),
);
});
it('seeds the Grenzwacht faction', async () => {
const reputationFactionRepository = 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(),
reputationFactionRepository,
);
await seedVisibleVerticalSlice(dataSource);
const faction = await dataSource
.getRepository(ReputationFaction)
.findOneBy({ key: 'border-guard' });
expect(faction).toMatchObject({ name: 'Grenzwacht', enabled: true });
});
it('seeds both turn-in definitions', async () => {
const turnInDefinitionRepository = 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(),
turnInDefinitionRepository,
);
await seedVisibleVerticalSlice(dataSource);
expect(
turnInDefinitionRepository.rows.map((row: Row) => row.key).sort(),
).toEqual(['ash-pelt-border-guard', 'bandit-insignia-border-guard']);
});
});

View File

@@ -16,7 +16,13 @@ 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 { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
import {
ITEM_DEFINITIONS,
LOOT_TABLES,
LOOT_TABLE_ENTRIES,
} from './item-content';
import {
ASH_RAT_LOOT_TABLE_ID,
ITEM_IDS,
@@ -26,6 +32,8 @@ import {
BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content';
import { REPUTATION_FACTIONS } from './reputation-content';
import { TURN_IN_DEFINITIONS } from './turn-in-content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
@@ -46,6 +54,9 @@ export async function seedVisibleVerticalSlice(
const itemRepository = dataSource.getRepository(ItemDefinition);
const lootTableRepository = dataSource.getRepository(LootTable);
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
const reputationFactionRepository =
dataSource.getRepository(ReputationFaction);
const turnInDefinitionRepository = dataSource.getRepository(TurnInDefinition);
const locations = [
{
@@ -125,6 +136,8 @@ export async function seedVisibleVerticalSlice(
'lootTableId',
'itemDefinitionId',
]);
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
await turnInDefinitionRepository.upsert(TURN_IN_DEFINITIONS, ['key']);
const monsters = [
{
@@ -135,9 +148,8 @@ export async function seedVisibleVerticalSlice(
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
@@ -150,9 +162,8 @@ export async function seedVisibleVerticalSlice(
maxHp: 55,
attack: 7,
armor: 0,
experienceReward: 11,
silverMin: 5,
silverMax: 9,
silverMin: 0,
silverMax: 0,
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
@@ -167,9 +178,8 @@ export async function seedVisibleVerticalSlice(
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
@@ -182,9 +192,8 @@ export async function seedVisibleVerticalSlice(
maxHp: 85,
attack: 11,
armor: 6,
experienceReward: 20,
silverMin: 12,
silverMax: 19,
silverMin: 0,
silverMax: 0,
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.
@@ -237,8 +246,7 @@ export async function seedVisibleVerticalSlice(
await characterRepository.insert({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
renown: 1,
silver: 0,
baseHp: 100,
baseAttack: 6,
@@ -248,7 +256,8 @@ export async function seedVisibleVerticalSlice(
}
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
const characterEquipmentRepository =
dataSource.getRepository(CharacterEquipment);
// Starting loadout is weapon-only -- no starter armor piece exists in
// content yet -- so the demo character's effective armor (sum of equipped