feat(combat): resolve victory rewards in the combat completion transaction

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 00:46:35 +02:00
parent d9585b166a
commit 2a8883d479
5 changed files with 233 additions and 9 deletions

View File

@@ -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())

View File

@@ -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],

View File

@@ -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);
});
});
});

View File

@@ -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<CombatDto | null> {
@@ -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,
};
}
}

View File

@@ -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())