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 () => {
|
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);
|
getCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
|
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 () => {
|
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);
|
getActiveCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
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 () => {
|
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);
|
performAction.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
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 { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { RewardsModule } from '../rewards/rewards.module';
|
||||||
import { TravelModule } from '../travel/travel.module';
|
import { TravelModule } from '../travel/travel.module';
|
||||||
import { CombatEngineService } from './combat-engine.service';
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
import { CombatController } from './combat.controller';
|
import { CombatController } from './combat.controller';
|
||||||
@@ -18,6 +19,7 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle
|
|||||||
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
|
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
|
||||||
TravelModule,
|
TravelModule,
|
||||||
CharactersModule,
|
CharactersModule,
|
||||||
|
RewardsModule,
|
||||||
],
|
],
|
||||||
controllers: [CombatController, HuntEncounterAttackController],
|
controllers: [CombatController, HuntEncounterAttackController],
|
||||||
providers: [CombatService, CombatEngineService],
|
providers: [CombatService, CombatEngineService],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Hunt } from '../hunting/entities/hunt.entity';
|
|||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { CombatRewardService } from '../rewards/combat-reward.service';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
import { CombatAction } from './combat-action.enum';
|
import { CombatAction } from './combat-action.enum';
|
||||||
import { CombatEngineService } from './combat-engine.service';
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
@@ -248,6 +249,20 @@ function fakeTravelService(
|
|||||||
} as unknown as TravelService;
|
} 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(
|
function createService(
|
||||||
options: { state?: FakeState; travelService?: TravelService } = {},
|
options: { state?: FakeState; travelService?: TravelService } = {},
|
||||||
) {
|
) {
|
||||||
@@ -261,6 +276,7 @@ function createService(
|
|||||||
travelService,
|
travelService,
|
||||||
combatEngine,
|
combatEngine,
|
||||||
characterCombatStats,
|
characterCombatStats,
|
||||||
|
fakeRewardService(),
|
||||||
);
|
);
|
||||||
return { dataSource, service, travelService };
|
return { dataSource, service, travelService };
|
||||||
}
|
}
|
||||||
@@ -664,4 +680,195 @@ describe('CombatService', () => {
|
|||||||
expect(reloaded.player.currentHp).toBe(0);
|
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 { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
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 { TravelService } from '../travel/travel.service';
|
||||||
import { TravelStatus } from '../travel/travel-status.enum';
|
import { TravelStatus } from '../travel/travel-status.enum';
|
||||||
import { CombatAction } from './combat-action.enum';
|
import { CombatAction } from './combat-action.enum';
|
||||||
@@ -57,6 +59,7 @@ export interface CombatDto {
|
|||||||
player: CombatPlayerDto;
|
player: CombatPlayerDto;
|
||||||
monster: CombatMonsterDto;
|
monster: CombatMonsterDto;
|
||||||
events: CombatEventDto[];
|
events: CombatEventDto[];
|
||||||
|
rewards: CombatRewardDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -66,6 +69,7 @@ export class CombatService {
|
|||||||
private readonly travelService: TravelService,
|
private readonly travelService: TravelService,
|
||||||
private readonly combatEngine: CombatEngineService,
|
private readonly combatEngine: CombatEngineService,
|
||||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
private readonly characterCombatStats: CharacterCombatStatsService,
|
||||||
|
private readonly combatRewards: CombatRewardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startCombat(
|
async startCombat(
|
||||||
@@ -146,7 +150,7 @@ export class CombatService {
|
|||||||
encounter.consumedAt = new Date();
|
encounter.consumedAt = new Date();
|
||||||
await encounters.save(encounter);
|
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();
|
throw combatNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [character, monster, events] = await Promise.all([
|
const [character, monster, events, rewards] = await Promise.all([
|
||||||
this.loadCharacter(combat.characterId),
|
this.loadCharacter(combat.characterId),
|
||||||
this.loadMonster(combat.monsterDefinitionId),
|
this.loadMonster(combat.monsterDefinitionId),
|
||||||
this.loadEvents(combat.id),
|
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> {
|
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
||||||
@@ -183,7 +188,7 @@ export class CombatService {
|
|||||||
this.loadEvents(combat.id),
|
this.loadEvents(combat.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, events);
|
return this.toCombatDto(combat, character.name, monster, events, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async performAction(
|
async performAction(
|
||||||
@@ -236,6 +241,14 @@ export class CombatService {
|
|||||||
await combatEvents.save(entity);
|
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([
|
const [character, monster, events] = await Promise.all([
|
||||||
this.loadCharacter(
|
this.loadCharacter(
|
||||||
combat.characterId,
|
combat.characterId,
|
||||||
@@ -248,7 +261,7 @@ export class CombatService {
|
|||||||
this.loadEvents(combat.id, combatEvents),
|
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,
|
playerName: string,
|
||||||
monster: MonsterDefinition,
|
monster: MonsterDefinition,
|
||||||
events: CombatEvent[],
|
events: CombatEvent[],
|
||||||
|
rewards: CombatRewardDto | null,
|
||||||
): CombatDto {
|
): CombatDto {
|
||||||
return {
|
return {
|
||||||
id: combat.id,
|
id: combat.id,
|
||||||
@@ -352,6 +366,7 @@ export class CombatService {
|
|||||||
target: event.target,
|
target: event.target,
|
||||||
amount: event.amount ?? undefined,
|
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 () => {
|
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);
|
startCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
|
|||||||
Reference in New Issue
Block a user