diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts new file mode 100644 index 0000000..5c35545 --- /dev/null +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -0,0 +1,374 @@ +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 as T; + } + + 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, + experienceReward: 16, + 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.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + rarity: ItemRarity.COMMON, + tier: 1, + requiredLevel: 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); + + const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id); + expect(reloaded.status).toBe(result.status); + expect(reloaded.round).toBe(result.round); + }); +});