From dce26e00ad160d6286154a9070b65a344b001e5b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 13:49:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(content):=20migrate=20ItemType,=20seed=20R?= =?UTF-8?q?=C3=A4uberabzeichen/Grenzwacht/turn-ins,=20zero=20direct=20mons?= =?UTF-8?q?ter=20rewards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/database/seeds/item-content.ts | 48 ++++++--- apps/api/src/database/seeds/item.constants.ts | 1 + .../src/database/seeds/reputation-content.ts | 25 +++++ .../api/src/database/seeds/turn-in-content.ts | 38 +++++++ .../seeds/vertical-slice.seed.spec.ts | 102 +++++++++++++++--- .../src/database/seeds/vertical-slice.seed.ts | 41 ++++--- 6 files changed, 208 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/database/seeds/reputation-content.ts create mode 100644 apps/api/src/database/seeds/turn-in-content.ts diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts index 8ac04ac..580aae6 100644 --- a/apps/api/src/database/seeds/item-content.ts +++ b/apps/api/src/database/seeds/item-content.ts @@ -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> = {}, + 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'), ]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts index 7a614ef..ab3d498 100644 --- a/apps/api/src/database/seeds/item.constants.ts +++ b/apps/api/src/database/seeds/item.constants.ts @@ -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; diff --git a/apps/api/src/database/seeds/reputation-content.ts b/apps/api/src/database/seeds/reputation-content.ts new file mode 100644 index 0000000..b1cb54b --- /dev/null +++ b/apps/api/src/database/seeds/reputation-content.ts @@ -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, + }, +]; diff --git a/apps/api/src/database/seeds/turn-in-content.ts b/apps/api/src/database/seeds/turn-in-content.ts new file mode 100644 index 0000000..34be562 --- /dev/null +++ b/apps/api/src/database/seeds/turn-in-content.ts @@ -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, + }, +]; 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 347ee58..4c97581 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -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; @@ -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']); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 3228315..2244609 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -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