feat(combat): resolve victory rewards in the combat completion transaction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user