import { DataSource, EntityManager, EntityTarget } from 'typeorm'; import { CharacterStatsService } from '../characters/character-stats.service'; import { Character } from '../characters/entities/character.entity'; import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; import { EquipmentService } from '../equipment/equipment.service'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { EquipmentSlot } from '../items/equipment-slot.enum'; import { ItemRarity } from '../items/item-rarity.enum'; import { ItemType } from '../items/item-type.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { CombatRewardService } from '../rewards/combat-reward.service'; import { TravelService } from '../travel/travel.service'; import { CombatAction } from './combat-action.enum'; import { CombatEngineService } from './combat-engine.service'; import { CombatService } from './combat.service'; import { CombatEvent } from './entities/combat-event.entity'; import { Combat } from './entities/combat.entity'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const HUNT_ID = '20000000-0000-4000-8000-000000000001'; const MONSTER_ID = '40000000-0000-4000-8000-000000000001'; const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001'; const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002'; const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001'; const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002'; interface FakeState { characters: Character[]; hunts: Hunt[]; huntEncounters: HuntEncounter[]; monsters: MonsterDefinition[]; combats: Combat[]; combatEvents: CombatEvent[]; itemDefinitions: ItemDefinition[]; characterItems: CharacterItem[]; characterEquipment: CharacterEquipment[]; } class FakeRepository { constructor( private readonly state: FakeState, private readonly target: EntityTarget, private readonly dataSource: FakeDataSource, ) {} findOne(options: { where: Partial; relations?: Record; lock?: { mode: string }; }): Promise { const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; return Promise.resolve( row ? this.withRelations(row, options.relations) : null, ); } findOneBy(where: Partial): Promise { return Promise.resolve( this.rows().find((row) => this.matches(row, where)) ?? null, ); } find(options: { where: Partial; relations?: Record; order?: Partial>; }): Promise { const matched = this.rows().filter((row) => this.matches(row, options.where), ); return Promise.resolve( matched.map((row) => this.withRelations(row, options.relations)), ); } count(options: { where: Partial }): Promise { return Promise.resolve( this.rows().filter((row) => this.matches(row, options.where)).length, ); } create(values: Partial): T { return { ...values } as T; } save(entity: T): Promise { if (!entity.id) { entity.id = this.dataSource.nextId(this.targetName()); } const rows = this.rows(); const index = rows.findIndex((row) => row.id === entity.id); if (index === -1) { rows.push(entity); } else { rows[index] = entity; } return Promise.resolve(entity); } private withRelations(row: T, relations?: Record): T { if (!relations) { return row; } const copy = { ...row } as T & Record; if (this.target === CharacterItem && relations['itemDefinition']) { const itemDefinitionId = (row as unknown as CharacterItem) .itemDefinitionId; copy['itemDefinition'] = this.state.itemDefinitions.find( (d) => d.id === itemDefinitionId, ); } if (this.target === CharacterEquipment && relations['characterItem']) { const characterItemId = (row as unknown as CharacterEquipment) .characterItemId; const characterItem = this.state.characterItems.find( (ci) => ci.id === characterItemId, ); copy['characterItem'] = characterItem ? { ...characterItem, itemDefinition: this.state.itemDefinitions.find( (d) => d.id === characterItem.itemDefinitionId, ), } : undefined; } return copy; } private rows(): T[] { if (this.target === Character) return this.state.characters as T[]; if (this.target === Hunt) return this.state.hunts as T[]; if (this.target === HuntEncounter) return this.state.huntEncounters as T[]; if (this.target === MonsterDefinition) return this.state.monsters as T[]; if (this.target === Combat) return this.state.combats as T[]; if (this.target === CombatEvent) return this.state.combatEvents as T[]; if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; if (this.target === CharacterItem) return this.state.characterItems as T[]; if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; throw new Error(`Unsupported repository ${this.targetName()}`); } private matches(row: T, where: Partial): boolean { return Object.entries(where).every( ([key, value]) => row[key as keyof T] === value, ); } private targetName(): string { return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; } } class FakeDataSource { private readonly idCounters = new Map(); constructor(public state: FakeState) {} getRepository(target: EntityTarget) { return new FakeRepository(this.state, target, this); } async transaction( work: (manager: EntityManager) => Promise, ): Promise { return work({ getRepository: (target: EntityTarget) => this.getRepository(target), } as unknown as EntityManager); } nextId(targetName: string): string { const next = (this.idCounters.get(targetName) ?? 0) + 1; this.idCounters.set(targetName, next); return `${targetName.toLowerCase()}-generated-${next}`; } } function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, currentLocationId: 'location-1', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as Character; } function monster( overrides: Partial = {}, ): MonsterDefinition { return { id: MONSTER_ID, key: 'road-bandit', name: 'Straßenräuber', level: 2, maxHp: 75, attack: 9, armor: 5, silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as MonsterDefinition; } function hunt(overrides: Partial = {}): Hunt { return { id: HUNT_ID, characterId: CHARACTER_ID, locationId: 'location-1', status: HuntStatus.ACTIVE, createdAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as Hunt; } function encounter( id: string, overrides: Partial = {}, ): HuntEncounter { return { id, huntId: HUNT_ID, monsterDefinitionId: MONSTER_ID, position: 0, status: HuntEncounterStatus.AVAILABLE, createdAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as HuntEncounter; } function itemDefinition( overrides: Partial = {}, ): ItemDefinition { return { id: WORN_SWORD_DEFINITION_ID, key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', description: '', type: ItemType.EQUIPMENT, equipmentSlot: EquipmentSlot.WEAPON, rarity: ItemRarity.COMMON, tier: 1, weaponDamage: 8, bonusHp: 0, bonusAttack: 0, bonusArmor: 0, sellPrice: 0, iconPath: '/images/items/worn-short-sword.png', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as ItemDefinition; } function fakeTravelService(): TravelService { return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), } as unknown as TravelService; } function fakeRewardService(): CombatRewardService { return { grantVictoryRewards: jest .fn() .mockResolvedValue({ experience: 0, silver: 0, items: [] }), loadRewards: jest.fn().mockResolvedValue(null), } as unknown as CombatRewardService; } function createHarness() { const state: FakeState = { characters: [character()], hunts: [hunt()], huntEncounters: [], monsters: [monster()], combats: [], combatEvents: [], itemDefinitions: [ itemDefinition(), itemDefinition({ id: BANDIT_BLADE_DEFINITION_ID, key: 'bandit-blade', name: 'Räuberklinge', weaponDamage: 11, bonusAttack: 1, iconPath: '/images/items/bandit-blade.png', }), ], characterItems: [ { id: WORN_SWORD_ITEM_ID, characterId: CHARACTER_ID, itemDefinitionId: WORN_SWORD_DEFINITION_ID, quantity: 1, createdAt: new Date(), updatedAt: new Date(), } as CharacterItem, { id: BANDIT_BLADE_ITEM_ID, characterId: CHARACTER_ID, itemDefinitionId: BANDIT_BLADE_DEFINITION_ID, quantity: 1, createdAt: new Date(), updatedAt: new Date(), } as CharacterItem, ], characterEquipment: [ { id: 'equip-1', characterId: CHARACTER_ID, slot: EquipmentSlot.WEAPON, characterItemId: WORN_SWORD_ITEM_ID, createdAt: new Date(), updatedAt: new Date(), } as CharacterEquipment, ], }; const dataSource = new FakeDataSource(state); const characterStats = new CharacterStatsService( dataSource as unknown as DataSource, ); const equipmentService = new EquipmentService( dataSource as unknown as DataSource, characterStats, ); const combatService = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), characterStats, fakeRewardService(), ); return { state, equipmentService, combatService }; } describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => { it('deals more damage against the same monster after the upgrade than before it', async () => { const { state, combatService, equipmentService } = createHarness(); state.huntEncounters.push(encounter('encounter-1')); const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); let beforeDamage = 0; let beforeResult = before; for ( let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1 ) { beforeResult = await combatService.performAction( CHARACTER_ID, before.id, CombatAction.ATTACK, ); if (round === 0) { beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp; } } // Equipment cannot change during an active combat (spec §46), so this // first fight must be resolved to completion before equipping. expect(beforeResult.status).not.toBe('ACTIVE'); await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); state.huntEncounters.push(encounter('encounter-2')); const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2'); const afterResult = await combatService.performAction( CHARACTER_ID, after.id, CombatAction.ATTACK, ); const afterDamage = after.monster.maxHp - afterResult.monster.currentHp; // (6+8) vs 5 armor -> round(14 * 60/65) = 13 expect(beforeDamage).toBe(13); // (7+11) vs 5 armor -> round(18 * 60/65) = 17 expect(afterDamage).toBe(17); expect(afterDamage).toBeGreaterThan(beforeDamage); }); it("rejects equipping during an active combat, and never retroactively rewrites a finished combat's snapshot", async () => { const { state, combatService, equipmentService } = createHarness(); state.huntEncounters.push(encounter('encounter-1')); const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); // Equipment cannot change while this combat is ACTIVE (spec §46). await expect( equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID), ).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' }); // Resolve the fight, then equip — the already-finished combat's snapshot // (status/round) must stay exactly what it was when the fight ended. let result = combat; for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) { result = await combatService.performAction( CHARACTER_ID, combat.id, CombatAction.ATTACK, ); } expect(result.status).not.toBe('ACTIVE'); await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); // The finished combat's playerState snapshot (written once at startCombat) // must not be retroactively rewritten by equipping after the fight ends. expect(state.combats[0].playerState).toEqual({ attack: 6, weaponDamage: 8, armor: 0, potionsRemaining: 2, }); const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id); expect(reloaded.status).toBe(result.status); expect(reloaded.round).toBe(result.round); }); });