Merge branch 'master' into worktree-encounter-status
This commit is contained in:
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -174,6 +175,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
@@ -248,6 +250,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 +277,7 @@ function createService(
|
||||
travelService,
|
||||
combatEngine,
|
||||
characterCombatStats,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { dataSource, service, travelService };
|
||||
}
|
||||
@@ -599,7 +616,10 @@ describe('CombatService', () => {
|
||||
});
|
||||
|
||||
it('rejects actions from a character who does not own the combat', async () => {
|
||||
const { service, combatId } = await startedCombat();
|
||||
const state = createState({
|
||||
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||
});
|
||||
const { service, combatId } = await startedCombat(state);
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.performAction(
|
||||
@@ -730,4 +750,215 @@ 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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user