import { DataSource, EntityManager, EntityTarget } from 'typeorm'; import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; import { Character } from '../characters/entities/character.entity'; 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 { 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 { CombatDomainError } from './combat.errors'; import { CombatService } from './combat.service'; import { CombatStatus } from './combat-status.enum'; import { CombatEvent } from './entities/combat-event.entity'; import { Combat } from './entities/combat.entity'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002'; const HUNT_ID = '20000000-0000-4000-8000-000000000001'; const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001'; const MONSTER_ID = '40000000-0000-4000-8000-000000000001'; interface FakeState { characters: Character[]; hunts: Hunt[]; huntEncounters: HuntEncounter[]; monsters: MonsterDefinition[]; combats: Combat[]; combatEvents: CombatEvent[]; } class FakeRepository { constructor( private readonly state: FakeState, private readonly target: EntityTarget, private readonly inTransaction: boolean, private readonly dataSource: FakeDataSource, ) {} findOne(options: { where: Partial; lock?: { mode: string }; }): Promise { if (options.lock) { if (!this.inTransaction) { throw new Error('Pessimistic locks require a transaction'); } this.dataSource.locks.push({ target: this.target, mode: options.lock.mode, }); } return Promise.resolve( this.rows().find((row) => this.matches(row, options.where)) ?? null, ); } findOneBy(where: Partial): Promise { return Promise.resolve( this.rows().find((row) => this.matches(row, where)) ?? null, ); } find(options: { where: Partial; order?: Partial>; }): Promise { const matched = this.rows().filter((row) => this.matches(row, options.where), ); const orderKey = options.order ? (Object.keys(options.order)[0] as keyof T) : undefined; if (orderKey) { const direction = options.order![orderKey] === 'DESC' ? -1 : 1; matched.sort((a, b) => { if (a[orderKey] === b[orderKey]) return 0; return a[orderKey] > b[orderKey] ? direction : -direction; }); } return Promise.resolve(matched); } 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 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[]; 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 FakeEntityManager { constructor( private readonly state: FakeState, private readonly dataSource: FakeDataSource, ) {} getRepository(target: EntityTarget) { return new FakeRepository(this.state, target, true, this.dataSource); } } class FakeDataSource { readonly locks: Array<{ target: EntityTarget; mode: string }> = []; private readonly idCounters = new Map(); constructor(public state: FakeState) {} getRepository(target: EntityTarget) { return new FakeRepository(this.state, target, false, this); } async transaction( work: (manager: EntityManager) => Promise, ): Promise { const draft = structuredClone(this.state); const result = await work( new FakeEntityManager(draft, this) as unknown as EntityManager, ); this.state = draft; return result; } 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: 'ash-rat', name: 'Aschenratte', level: 1, maxHp: 45, attack: 5, armor: 0, experienceReward: 8, silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, }; } 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 huntEncounter(overrides: Partial = {}): HuntEncounter { return { id: ENCOUNTER_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 createState(overrides: Partial = {}): FakeState { return { characters: [character()], hunts: [hunt()], huntEncounters: [huntEncounter()], monsters: [monster()], combats: [], combatEvents: [], ...overrides, }; } function fakeTravelService( status: 'IDLE' | 'TRAVELLING' = 'IDLE', ): TravelService { return { completeTravelIfDue: jest.fn().mockResolvedValue({ status }), } as unknown as TravelService; } function fakeRewardService( overrides: Partial<{ grantVictoryRewards: jest.Mock; loadRewards: jest.Mock; }> = {}, ): CombatRewardService { return { grantVictoryRewards: overrides.grantVictoryRewards ?? jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [] }), loadRewards: overrides.loadRewards ?? jest.fn().mockResolvedValue(null), } as unknown as CombatRewardService; } function createService( options: { state?: FakeState; travelService?: TravelService } = {}, ) { const state = options.state ?? createState(); const dataSource = new FakeDataSource(state); const travelService = options.travelService ?? fakeTravelService(); const combatEngine = new CombatEngineService(); const characterCombatStats = new CharacterCombatStatsService(); const service = new CombatService( dataSource as unknown as DataSource, travelService, combatEngine, characterCombatStats, fakeRewardService(), ); return { dataSource, service, travelService }; } async function expectCombatDomainError( promise: Promise, code: string, ): Promise { let error: unknown; try { await promise; } catch (cause) { error = cause; } expect(error).toBeInstanceOf(CombatDomainError); if (!(error instanceof CombatDomainError)) { throw new Error('Expected CombatDomainError'); } expect(error.code).toBe(code); } describe('CombatService', () => { describe('startCombat', () => { it('starts an ACTIVE combat with snapshotted stats and full HP', async () => { const { dataSource, service } = createService(); const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(combat.status).toBe('ACTIVE'); expect(combat.round).toBe(1); expect(combat.player).toEqual({ name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, }); expect(combat.monster).toEqual({ key: 'ash-rat', name: 'Aschenratte', level: 1, maxHp: 45, currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', }); expect(combat.events).toEqual([]); expect(dataSource.state.combats).toHaveLength(1); expect(dataSource.state.combats[0]).toMatchObject({ characterId: CHARACTER_ID, huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, playerState: { attack: 6, weaponDamage: 8, armor: 6 }, monsterState: { attack: 5, armor: 0 }, }); }); it('marks the encounter as IN_PROGRESS', async () => { const { dataSource, service } = createService(); await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(dataSource.state.huntEncounters[0].status).toBe( HuntEncounterStatus.IN_PROGRESS, ); }); it('rejects an unknown encounter id', async () => { const { service } = createService(); await expectCombatDomainError( service.startCombat(CHARACTER_ID, 'unknown-id'), 'HUNT_ENCOUNTER_NOT_FOUND', ); }); it('rejects an already-defeated encounter, and does not create a second combat', async () => { const state = createState({ huntEncounters: [ huntEncounter({ status: HuntEncounterStatus.DEFEATED }), ], }); const { dataSource, service } = createService({ state }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'HUNT_ENCOUNTER_ALREADY_CONSUMED', ); expect(dataSource.state.combats).toHaveLength(0); }); it('rejects an encounter whose fight is still IN_PROGRESS', async () => { const state = createState({ huntEncounters: [ huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }), ], }); const { dataSource, service } = createService({ state }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'HUNT_ENCOUNTER_ALREADY_CONSUMED', ); expect(dataSource.state.combats).toHaveLength(0); }); it('rejects an encounter belonging to a different character', async () => { const state = createState({ characters: [character(), character({ id: OTHER_CHARACTER_ID })], }); const { service } = createService({ state }); await expectCombatDomainError( service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID), 'INVALID_HUNT_ENCOUNTER', ); }); it('rejects an encounter whose hunt is no longer ACTIVE', async () => { const state = createState({ hunts: [hunt({ status: HuntStatus.SUPERSEDED })], }); const { service } = createService({ state }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'INVALID_HUNT_ENCOUNTER', ); }); it('rejects starting combat while the character is travelling', async () => { const { service } = createService({ travelService: fakeTravelService('TRAVELLING'), }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'CHARACTER_TRAVELLING', ); }); it('rejects starting a second combat while one is already ACTIVE', async () => { const state = createState({ combats: [ { id: 'combat-existing', characterId: CHARACTER_ID, huntEncounterId: 'other-encounter', monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, round: 1, playerMaxHp: 100, playerCurrentHp: 100, monsterMaxHp: 45, monsterCurrentHp: 45, playerState: { attack: 6, weaponDamage: 8, armor: 6 }, monsterState: { attack: 5, armor: 0 }, createdAt: new Date(), updatedAt: new Date(), completedAt: null, } as Combat, ], }); const { service } = createService({ state }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'COMBAT_ALREADY_ACTIVE', ); }); it('locks the character and the active-combat lookup', async () => { const { dataSource, service } = createService(); await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(dataSource.locks).toEqual( expect.arrayContaining([ { target: Character, mode: 'pessimistic_write' }, { target: Combat, mode: 'pessimistic_write' }, ]), ); }); }); describe('performAction', () => { async function startedCombat(state = createState()) { const context = createService({ state }); const combat = await context.service.startCombat( CHARACTER_ID, ENCOUNTER_ID, ); return { ...context, combatId: combat.id }; } it('resolves ATTACK, persists HP/round changes, and returns them', async () => { const { dataSource, service, combatId } = await startedCombat(); const result = await service.performAction( CHARACTER_ID, combatId, CombatAction.ATTACK, ); expect(result.status).toBe('ACTIVE'); expect(result.round).toBe(2); expect(result.monster.currentHp).toBe(45 - 14); expect(result.player.currentHp).toBe(100 - 5); expect(dataSource.state.combats[0].round).toBe(2); expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31); expect(dataSource.state.combats[0].playerCurrentHp).toBe(95); }); it('persists ordered, sequential CombatEvents across multiple rounds', async () => { const { dataSource, service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); const events = dataSource.state.combatEvents .filter((event) => event.combatId === combatId) .sort((a, b) => a.sequence - b.sequence); expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]); expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]); expect(events[0]).toMatchObject({ source: 'PLAYER', target: 'MONSTER', type: 'DAMAGE', amount: 14, }); expect(events[1]).toMatchObject({ source: 'MONSTER', target: 'PLAYER', type: 'DAMAGE', amount: 5, }); }); it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const { dataSource, service, combatId } = await startedCombat(state); const result = await service.performAction( CHARACTER_ID, combatId, CombatAction.ATTACK, ); expect(result.status).toBe('WON'); expect(dataSource.state.combats[0].completedAt).not.toBeNull(); await expectCombatDomainError( service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK), 'COMBAT_ALREADY_FINISHED', ); }); it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => { const state = createState({ characters: [character({ baseHp: 1 })], }); const { dataSource, service, combatId } = await startedCombat(state); const result = await service.performAction( CHARACTER_ID, combatId, CombatAction.ATTACK, ); expect(result.status).toBe('LOST'); expect(dataSource.state.combats[0].status).toBe(CombatStatus.LOST); expect(dataSource.state.combats[0].completedAt).not.toBeNull(); await expectCombatDomainError( service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK), 'COMBAT_ALREADY_FINISHED', ); }); it('marks the encounter DEFEATED when the fight is won', async () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.huntEncounters[0].status).toBe( HuntEncounterStatus.DEFEATED, ); }); it('frees the encounter for another attempt when the fight is lost', async () => { const state = createState({ characters: [character({ baseHp: 1 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.huntEncounters[0].status).toBe( HuntEncounterStatus.AVAILABLE, ); }); it('leaves the encounter IN_PROGRESS while the fight continues', async () => { const { dataSource, service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.huntEncounters[0].status).toBe( HuntEncounterStatus.IN_PROGRESS, ); }); it('lets a lost encounter be fought again as a fresh combat', async () => { const state = createState({ characters: [character({ baseHp: 1 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(retry.id).not.toBe(combatId); expect(retry.status).toBe('ACTIVE'); expect(retry.round).toBe(1); expect(retry.player.currentHp).toBe(retry.player.maxHp); expect(dataSource.state.combats).toHaveLength(2); expect(dataSource.state.huntEncounters[0].status).toBe( HuntEncounterStatus.IN_PROGRESS, ); }); it('rejects actions on an unknown combat id', async () => { const { service } = createService(); await expectCombatDomainError( service.performAction( CHARACTER_ID, 'unknown-combat', CombatAction.ATTACK, ), 'COMBAT_NOT_FOUND', ); }); it('rejects actions from a character who does not own the combat', async () => { const state = createState({ characters: [character(), character({ id: OTHER_CHARACTER_ID })], }); const { service, combatId } = await startedCombat(state); await expectCombatDomainError( service.performAction( OTHER_CHARACTER_ID, combatId, CombatAction.ATTACK, ), 'COMBAT_NOT_FOUND', ); }); it('locks the combat row for the duration of the action', async () => { const { dataSource, service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.locks).toEqual( expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]), ); }); }); describe('getCombat', () => { it('returns the persisted state and ordered events after a refresh', async () => { const context = createService(); const started = await context.service.startCombat( CHARACTER_ID, ENCOUNTER_ID, ); await context.service.performAction( CHARACTER_ID, started.id, CombatAction.ATTACK, ); const reloaded = await context.service.getCombat( CHARACTER_ID, started.id, ); expect(reloaded.round).toBe(2); expect(reloaded.monster.currentHp).toBe(31); expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]); }); it('rejects an unknown combat id', async () => { const { service } = createService(); await expectCombatDomainError( service.getCombat(CHARACTER_ID, 'unknown'), 'COMBAT_NOT_FOUND', ); }); it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => { const context = createService(); const started = await context.service.startCombat( CHARACTER_ID, ENCOUNTER_ID, ); await context.service.performAction( CHARACTER_ID, started.id, CombatAction.ATTACK, ); const active = await context.service.getActiveCombat(CHARACTER_ID); expect(active?.id).toBe(started.id); expect(active?.status).toBe('ACTIVE'); expect(active?.round).toBe(2); }); it('resolves null when the character has no ACTIVE combat', async () => { const { service } = createService(); await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull(); }); it('resolves null once the only combat has finished', async () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const context = createService({ state }); const started = await context.service.startCombat( CHARACTER_ID, ENCOUNTER_ID, ); await context.service.performAction( CHARACTER_ID, started.id, CombatAction.ATTACK, ); await expect( context.service.getActiveCombat(CHARACTER_ID), ).resolves.toBeNull(); }); it('does not resolve another character ACTIVE combat', async () => { const context = createService(); await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID); await expect( context.service.getActiveCombat(OTHER_CHARACTER_ID), ).resolves.toBeNull(); }); it('keeps returning LOST after the combat has ended', async () => { const state = createState({ characters: [character({ baseHp: 1 })], }); const context = createService({ state }); const started = await context.service.startCombat( CHARACTER_ID, ENCOUNTER_ID, ); await context.service.performAction( CHARACTER_ID, started.id, CombatAction.ATTACK, ); const reloaded = await context.service.getCombat( CHARACTER_ID, started.id, ); expect(reloaded.status).toBe('LOST'); expect(reloaded.player.currentHp).toBe(0); }); }); describe('rewards', () => { it('grants rewards inside the same transaction when the round ends in victory', async () => { const dataSource = new FakeDataSource( createState({ combats: [ { id: 'combat-1', characterId: CHARACTER_ID, huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, round: 3, playerMaxHp: 100, playerCurrentHp: 80, monsterMaxHp: 45, monsterCurrentHp: 1, playerState: { attack: 6, weaponDamage: 8, armor: 6 }, monsterState: { attack: 5, armor: 0 }, completedAt: null, } as Combat, ], }), ); const rewards = fakeRewardService({ grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [], }), }); const service = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards, ); const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); expect(result.status).toBe(CombatStatus.WON); expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] }); expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1); // The reward service must receive the transaction manager, not the data // source: `expect.anything()` would pass even if the code handed over // `this.dataSource`, so assert on the captured argument's identity. const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock .calls[0][0]; expect(passedManager).not.toBe(dataSource); expect(rewards.grantVictoryRewards).toHaveBeenCalledWith( passedManager, expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }), ); }); it('grants no rewards when the round ends in defeat', async () => { const dataSource = new FakeDataSource( createState({ combats: [ { id: 'combat-1', characterId: CHARACTER_ID, huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, round: 3, playerMaxHp: 100, playerCurrentHp: 1, monsterMaxHp: 45, monsterCurrentHp: 45, playerState: { attack: 1, weaponDamage: 1, armor: 0 }, monsterState: { attack: 99, armor: 99 }, completedAt: null, } as Combat, ], }), ); const rewards = fakeRewardService(); const service = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards, ); const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); expect(result.status).toBe(CombatStatus.LOST); expect(result.rewards).toBeNull(); expect(rewards.grantVictoryRewards).not.toHaveBeenCalled(); }); it('replays the persisted reward when a finished combat is read again', async () => { const persisted = { experience: 16, silver: 12, items: [ { characterItemId: 'character-item-1', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png', }, quantity: 1, }, ], }; const dataSource = new FakeDataSource( createState({ combats: [ { id: 'combat-1', characterId: CHARACTER_ID, huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.WON, round: 5, playerMaxHp: 100, playerCurrentHp: 62, monsterMaxHp: 45, monsterCurrentHp: 0, playerState: { attack: 6, weaponDamage: 8, armor: 6 }, monsterState: { attack: 5, armor: 0 }, completedAt: new Date('2026-08-19T09:00:00.000Z'), } as Combat, ], }), ); const rewards = fakeRewardService({ loadRewards: jest.fn().mockResolvedValue(persisted), grantVictoryRewards: jest.fn(), }); const service = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards, ); const result = await service.getCombat(CHARACTER_ID, 'combat-1'); expect(result.rewards).toEqual(persisted); // Reading must never grant: only the ACTIVE -> WON transition does. expect(rewards.grantVictoryRewards).not.toHaveBeenCalled(); }); it('persists nothing at all when reward resolution fails mid-transaction', async () => { const dataSource = new FakeDataSource( createState({ combats: [ { id: 'combat-1', characterId: CHARACTER_ID, huntEncounterId: ENCOUNTER_ID, monsterDefinitionId: MONSTER_ID, status: CombatStatus.ACTIVE, round: 3, playerMaxHp: 100, playerCurrentHp: 80, monsterMaxHp: 45, monsterCurrentHp: 1, playerState: { attack: 6, weaponDamage: 8, armor: 6 }, monsterState: { attack: 5, armor: 0 }, completedAt: null, } as Combat, ], }), ); const service = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), fakeRewardService({ // Genuinely write XP/silver through the transaction's manager // before failing, so the assertions below prove the rollback // discards those writes rather than passing vacuously because // nothing was ever written. grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => { const characters = manager.getRepository(Character); const combatCharacter = await characters.findOneBy({ id: CHARACTER_ID, }); if (combatCharacter) { combatCharacter.experience += 8; combatCharacter.silver += 6; await characters.save(combatCharacter); } throw new Error('reward persistence failed'); }), }), ); await expect( service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK), ).rejects.toThrow('reward persistence failed'); // The whole round rolled back: the combat is still ACTIVE and unmodified, // so no half-granted state can survive. expect(dataSource.state.combats[0].status).toBe(CombatStatus.ACTIVE); expect(dataSource.state.combats[0].monsterCurrentHp).toBe(1); expect(dataSource.state.combatEvents).toHaveLength(0); expect(dataSource.state.characters[0].experience).toBe(0); expect(dataSource.state.characters[0].silver).toBe(0); }); }); });