diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts index 578a32e..ab89048 100644 --- a/apps/api/src/combat/combat.controller.spec.ts +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -34,7 +34,7 @@ describe('CombatController', () => { }); it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; getCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200); @@ -44,7 +44,7 @@ describe('CombatController', () => { }); it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null }; getActiveCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); @@ -65,7 +65,7 @@ describe('CombatController', () => { }); it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null }; performAction.mockResolvedValue(combat); const response = await request(app.getHttpServer()) diff --git a/apps/api/src/combat/combat.module.ts b/apps/api/src/combat/combat.module.ts index 10ace1b..432e59e 100644 --- a/apps/api/src/combat/combat.module.ts +++ b/apps/api/src/combat/combat.module.ts @@ -5,6 +5,7 @@ import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RewardsModule } from '../rewards/rewards.module'; import { TravelModule } from '../travel/travel.module'; import { CombatEngineService } from './combat-engine.service'; import { CombatController } from './combat.controller'; @@ -18,6 +19,7 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]), TravelModule, CharactersModule, + RewardsModule, ], controllers: [CombatController, HuntEncounterAttackController], providers: [CombatService, CombatEngineService], diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index bb8587b..2afeabf 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -5,6 +5,7 @@ import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; 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'; @@ -248,6 +249,20 @@ function fakeTravelService( } 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 } = {}, ) { @@ -261,6 +276,7 @@ function createService( travelService, combatEngine, characterCombatStats, + fakeRewardService(), ); return { dataSource, service, travelService }; } @@ -664,4 +680,195 @@ describe('CombatService', () => { 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 receives the transaction manager, not the data source. + expect(rewards.grantVictoryRewards).toHaveBeenCalledWith( + expect.anything(), + 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({ + grantVictoryRewards: jest.fn().mockRejectedValue(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); + }); + }); }); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 60eb4e0..f71013a 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -6,6 +6,8 @@ import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { CombatRewardService } from '../rewards/combat-reward.service'; +import type { CombatRewardDto } from '../rewards/combat-reward.service'; import { TravelService } from '../travel/travel.service'; import { TravelStatus } from '../travel/travel-status.enum'; import { CombatAction } from './combat-action.enum'; @@ -57,6 +59,7 @@ export interface CombatDto { player: CombatPlayerDto; monster: CombatMonsterDto; events: CombatEventDto[]; + rewards: CombatRewardDto | null; } @Injectable() @@ -66,6 +69,7 @@ export class CombatService { private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, private readonly characterCombatStats: CharacterCombatStatsService, + private readonly combatRewards: CombatRewardService, ) {} async startCombat( @@ -146,7 +150,7 @@ export class CombatService { encounter.consumedAt = new Date(); await encounters.save(encounter); - return this.toCombatDto(combat, character.name, monster, []); + return this.toCombatDto(combat, character.name, monster, [], null); }); } @@ -159,13 +163,14 @@ export class CombatService { throw combatNotFound(); } - const [character, monster, events] = await Promise.all([ + const [character, monster, events, rewards] = await Promise.all([ this.loadCharacter(combat.characterId), this.loadMonster(combat.monsterDefinitionId), this.loadEvents(combat.id), + this.combatRewards.loadRewards(combat.id), ]); - return this.toCombatDto(combat, character.name, monster, events); + return this.toCombatDto(combat, character.name, monster, events, rewards); } async getActiveCombat(characterId: string): Promise { @@ -183,7 +188,7 @@ export class CombatService { this.loadEvents(combat.id), ]); - return this.toCombatDto(combat, character.name, monster, events); + return this.toCombatDto(combat, character.name, monster, events, null); } async performAction( @@ -236,6 +241,14 @@ export class CombatService { await combatEvents.save(entity); } + // The engine decided the outcome; rewards are resolved here, outside it + // (spec §30). Running inside this transaction means a reward failure + // rolls the whole round back rather than leaving a half-granted victory. + const rewards = + combat.status === CombatStatus.WON + ? await this.combatRewards.grantVictoryRewards(manager, combat) + : null; + const [character, monster, events] = await Promise.all([ this.loadCharacter( combat.characterId, @@ -248,7 +261,7 @@ export class CombatService { this.loadEvents(combat.id, combatEvents), ]); - return this.toCombatDto(combat, character.name, monster, events); + return this.toCombatDto(combat, character.name, monster, events, rewards); }); } @@ -326,6 +339,7 @@ export class CombatService { playerName: string, monster: MonsterDefinition, events: CombatEvent[], + rewards: CombatRewardDto | null, ): CombatDto { return { id: combat.id, @@ -352,6 +366,7 @@ export class CombatService { target: event.target, amount: event.amount ?? undefined, })), + rewards, }; } } diff --git a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts index a862876..3d94e0e 100644 --- a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts +++ b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts @@ -28,7 +28,7 @@ describe('HuntEncounterAttackController', () => { }); it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; startCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer())