Fix a lock-order inversion Task 8 introduced: performAction locked the combat row first and, inside grantVictoryRewards, the character row second -- the opposite order to startCombat (character, then combat), creating a deadlock cycle for two concurrent requests on the same character. performAction now locks the character first via the existing lockCharacter helper, matching startCombat; the later re-lock inside grantVictoryRewards is a no-op within the same transaction. Also strengthen the test that guards the transaction contract for grantVictoryRewards: expect.anything() would have passed even if the data source were handed over instead of the transaction manager, since CombatRewardService has no runtime guard against that substitution. The test now asserts on the captured argument's identity. Verified this is load-bearing by temporarily passing the data source in place of the manager and confirming the test fails. Finally, make the rollback test's unchanged-XP/silver assertions real: the fake grantVictoryRewards now writes through the transaction manager before throwing, so the assertions prove the rollback discarded those writes instead of passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
898 lines
28 KiB
TypeScript
898 lines
28 KiB
TypeScript
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
|
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
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';
|
|
import { CombatDomainError } from './combat.errors';
|
|
import { CombatService } from './combat.service';
|
|
import { CombatStatus } from './combat-status.enum';
|
|
import { CombatEvent } from './entities/combat-event.entity';
|
|
import { Combat } from './entities/combat.entity';
|
|
|
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
|
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
|
|
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
|
|
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
|
|
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
|
|
|
|
interface FakeState {
|
|
characters: Character[];
|
|
hunts: Hunt[];
|
|
huntEncounters: HuntEncounter[];
|
|
monsters: MonsterDefinition[];
|
|
combats: Combat[];
|
|
combatEvents: CombatEvent[];
|
|
}
|
|
|
|
class FakeRepository<T extends { id: string }> {
|
|
constructor(
|
|
private readonly state: FakeState,
|
|
private readonly target: EntityTarget<T>,
|
|
private readonly inTransaction: boolean,
|
|
private readonly dataSource: FakeDataSource,
|
|
) {}
|
|
|
|
findOne(options: {
|
|
where: Partial<T>;
|
|
lock?: { mode: string };
|
|
}): Promise<T | null> {
|
|
if (options.lock) {
|
|
if (!this.inTransaction) {
|
|
throw new Error('Pessimistic locks require a transaction');
|
|
}
|
|
this.dataSource.locks.push({
|
|
target: this.target,
|
|
mode: options.lock.mode,
|
|
});
|
|
}
|
|
return Promise.resolve(
|
|
this.rows().find((row) => this.matches(row, options.where)) ?? null,
|
|
);
|
|
}
|
|
|
|
findOneBy(where: Partial<T>): Promise<T | null> {
|
|
return Promise.resolve(
|
|
this.rows().find((row) => this.matches(row, where)) ?? null,
|
|
);
|
|
}
|
|
|
|
find(options: {
|
|
where: Partial<T>;
|
|
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
|
}): Promise<T[]> {
|
|
const matched = this.rows().filter((row) =>
|
|
this.matches(row, options.where),
|
|
);
|
|
const orderKey = options.order
|
|
? (Object.keys(options.order)[0] as keyof T)
|
|
: undefined;
|
|
if (orderKey) {
|
|
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
|
|
matched.sort((a, b) => {
|
|
if (a[orderKey] === b[orderKey]) return 0;
|
|
return a[orderKey] > b[orderKey] ? direction : -direction;
|
|
});
|
|
}
|
|
return Promise.resolve(matched);
|
|
}
|
|
|
|
count(options: { where: Partial<T> }): Promise<number> {
|
|
return Promise.resolve(
|
|
this.rows().filter((row) => this.matches(row, options.where)).length,
|
|
);
|
|
}
|
|
|
|
create(values: Partial<T>): T {
|
|
return { ...values } as T;
|
|
}
|
|
|
|
save(entity: T): Promise<T> {
|
|
if (!entity.id) {
|
|
entity.id = this.dataSource.nextId(this.targetName());
|
|
}
|
|
const rows = this.rows();
|
|
const index = rows.findIndex((row) => row.id === entity.id);
|
|
if (index === -1) {
|
|
rows.push(entity);
|
|
} else {
|
|
rows[index] = entity;
|
|
}
|
|
return Promise.resolve(entity);
|
|
}
|
|
|
|
private rows(): T[] {
|
|
if (this.target === Character) return this.state.characters as T[];
|
|
if (this.target === Hunt) return this.state.hunts as T[];
|
|
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
|
|
if (this.target === MonsterDefinition) return this.state.monsters as T[];
|
|
if (this.target === Combat) return this.state.combats as T[];
|
|
if (this.target === CombatEvent) return this.state.combatEvents as T[];
|
|
throw new Error(`Unsupported repository ${this.targetName()}`);
|
|
}
|
|
|
|
private matches(row: T, where: Partial<T>): boolean {
|
|
return Object.entries(where).every(
|
|
([key, value]) => row[key as keyof T] === value,
|
|
);
|
|
}
|
|
|
|
private targetName(): string {
|
|
return typeof this.target === 'function'
|
|
? this.target.name
|
|
: 'EntitySchema';
|
|
}
|
|
}
|
|
|
|
class FakeEntityManager {
|
|
constructor(
|
|
private readonly state: FakeState,
|
|
private readonly dataSource: FakeDataSource,
|
|
) {}
|
|
|
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
|
return new FakeRepository(this.state, target, true, this.dataSource);
|
|
}
|
|
}
|
|
|
|
class FakeDataSource {
|
|
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
|
|
private readonly idCounters = new Map<string, number>();
|
|
|
|
constructor(public state: FakeState) {}
|
|
|
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
|
return new FakeRepository(this.state, target, false, this);
|
|
}
|
|
|
|
async transaction<T>(
|
|
work: (manager: EntityManager) => Promise<T>,
|
|
): Promise<T> {
|
|
const draft = structuredClone(this.state);
|
|
const result = await work(
|
|
new FakeEntityManager(draft, this) as unknown as EntityManager,
|
|
);
|
|
this.state = draft;
|
|
return result;
|
|
}
|
|
|
|
nextId(targetName: string): string {
|
|
const next = (this.idCounters.get(targetName) ?? 0) + 1;
|
|
this.idCounters.set(targetName, next);
|
|
return `${targetName.toLowerCase()}-generated-${next}`;
|
|
}
|
|
}
|
|
|
|
function character(overrides: Partial<Character> = {}): Character {
|
|
return {
|
|
id: CHARACTER_ID,
|
|
name: 'Aric Duskwalker',
|
|
level: 1,
|
|
experience: 0,
|
|
silver: 0,
|
|
baseHp: 100,
|
|
baseAttack: 6,
|
|
currentHp: 100,
|
|
currentLocationId: 'location-1',
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
...overrides,
|
|
} as Character;
|
|
}
|
|
|
|
function monster(
|
|
overrides: Partial<MonsterDefinition> = {},
|
|
): MonsterDefinition {
|
|
return {
|
|
id: MONSTER_ID,
|
|
key: 'ash-rat',
|
|
name: 'Aschenratte',
|
|
level: 1,
|
|
maxHp: 45,
|
|
attack: 5,
|
|
armor: 0,
|
|
experienceReward: 8,
|
|
silverMin: 4,
|
|
silverMax: 7,
|
|
artworkPath: '/images/monsters/ash-rat.png',
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function hunt(overrides: Partial<Hunt> = {}): Hunt {
|
|
return {
|
|
id: HUNT_ID,
|
|
characterId: CHARACTER_ID,
|
|
locationId: 'location-1',
|
|
status: HuntStatus.ACTIVE,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
...overrides,
|
|
} as Hunt;
|
|
}
|
|
|
|
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
|
return {
|
|
id: ENCOUNTER_ID,
|
|
huntId: HUNT_ID,
|
|
monsterDefinitionId: MONSTER_ID,
|
|
position: 0,
|
|
consumedAt: null,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
...overrides,
|
|
} as HuntEncounter;
|
|
}
|
|
|
|
function createState(overrides: Partial<FakeState> = {}): FakeState {
|
|
return {
|
|
characters: [character()],
|
|
hunts: [hunt()],
|
|
huntEncounters: [huntEncounter()],
|
|
monsters: [monster()],
|
|
combats: [],
|
|
combatEvents: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeTravelService(
|
|
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
|
): TravelService {
|
|
return {
|
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
|
|
} 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 } = {},
|
|
) {
|
|
const state = options.state ?? createState();
|
|
const dataSource = new FakeDataSource(state);
|
|
const travelService = options.travelService ?? fakeTravelService();
|
|
const combatEngine = new CombatEngineService();
|
|
const characterCombatStats = new CharacterCombatStatsService();
|
|
const service = new CombatService(
|
|
dataSource as unknown as DataSource,
|
|
travelService,
|
|
combatEngine,
|
|
characterCombatStats,
|
|
fakeRewardService(),
|
|
);
|
|
return { dataSource, service, travelService };
|
|
}
|
|
|
|
async function expectCombatDomainError(
|
|
promise: Promise<unknown>,
|
|
code: string,
|
|
): Promise<void> {
|
|
let error: unknown;
|
|
try {
|
|
await promise;
|
|
} catch (cause) {
|
|
error = cause;
|
|
}
|
|
expect(error).toBeInstanceOf(CombatDomainError);
|
|
if (!(error instanceof CombatDomainError)) {
|
|
throw new Error('Expected CombatDomainError');
|
|
}
|
|
expect(error.code).toBe(code);
|
|
}
|
|
|
|
describe('CombatService', () => {
|
|
describe('startCombat', () => {
|
|
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
expect(combat.status).toBe('ACTIVE');
|
|
expect(combat.round).toBe(1);
|
|
expect(combat.player).toEqual({
|
|
name: 'Aric Duskwalker',
|
|
maxHp: 100,
|
|
currentHp: 100,
|
|
});
|
|
expect(combat.monster).toEqual({
|
|
key: 'ash-rat',
|
|
name: 'Aschenratte',
|
|
level: 1,
|
|
maxHp: 45,
|
|
currentHp: 45,
|
|
artworkPath: '/images/monsters/ash-rat.png',
|
|
});
|
|
expect(combat.events).toEqual([]);
|
|
expect(dataSource.state.combats).toHaveLength(1);
|
|
expect(dataSource.state.combats[0]).toMatchObject({
|
|
characterId: CHARACTER_ID,
|
|
huntEncounterId: ENCOUNTER_ID,
|
|
monsterDefinitionId: MONSTER_ID,
|
|
status: CombatStatus.ACTIVE,
|
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
|
monsterState: { attack: 5, armor: 0 },
|
|
});
|
|
});
|
|
|
|
it('marks the encounter as consumed', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
|
|
});
|
|
|
|
it('rejects an unknown encounter id', async () => {
|
|
const { service } = createService();
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(CHARACTER_ID, 'unknown-id'),
|
|
'HUNT_ENCOUNTER_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
|
|
const state = createState({
|
|
huntEncounters: [
|
|
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
|
|
],
|
|
});
|
|
const { dataSource, service } = createService({ state });
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
|
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
|
|
);
|
|
expect(dataSource.state.combats).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects an encounter belonging to a different character', async () => {
|
|
const state = createState({
|
|
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
|
});
|
|
const { service } = createService({ state });
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
|
|
'INVALID_HUNT_ENCOUNTER',
|
|
);
|
|
});
|
|
|
|
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
|
|
const state = createState({
|
|
hunts: [hunt({ status: HuntStatus.SUPERSEDED })],
|
|
});
|
|
const { service } = createService({ state });
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
|
'INVALID_HUNT_ENCOUNTER',
|
|
);
|
|
});
|
|
|
|
it('rejects starting combat while the character is travelling', async () => {
|
|
const { service } = createService({
|
|
travelService: fakeTravelService('TRAVELLING'),
|
|
});
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
|
'CHARACTER_TRAVELLING',
|
|
);
|
|
});
|
|
|
|
it('rejects starting a second combat while one is already ACTIVE', async () => {
|
|
const state = createState({
|
|
combats: [
|
|
{
|
|
id: 'combat-existing',
|
|
characterId: CHARACTER_ID,
|
|
huntEncounterId: 'other-encounter',
|
|
monsterDefinitionId: MONSTER_ID,
|
|
status: CombatStatus.ACTIVE,
|
|
round: 1,
|
|
playerMaxHp: 100,
|
|
playerCurrentHp: 100,
|
|
monsterMaxHp: 45,
|
|
monsterCurrentHp: 45,
|
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
|
monsterState: { attack: 5, armor: 0 },
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
completedAt: null,
|
|
} as Combat,
|
|
],
|
|
});
|
|
const { service } = createService({ state });
|
|
|
|
await expectCombatDomainError(
|
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
|
'COMBAT_ALREADY_ACTIVE',
|
|
);
|
|
});
|
|
|
|
it('locks the character and the active-combat lookup', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
expect(dataSource.locks).toEqual(
|
|
expect.arrayContaining([
|
|
{ target: Character, mode: 'pessimistic_write' },
|
|
{ target: Combat, mode: 'pessimistic_write' },
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('performAction', () => {
|
|
async function startedCombat(state = createState()) {
|
|
const context = createService({ state });
|
|
const combat = await context.service.startCombat(
|
|
CHARACTER_ID,
|
|
ENCOUNTER_ID,
|
|
);
|
|
return { ...context, combatId: combat.id };
|
|
}
|
|
|
|
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
|
|
const { dataSource, service, combatId } = await startedCombat();
|
|
|
|
const result = await service.performAction(
|
|
CHARACTER_ID,
|
|
combatId,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
expect(result.status).toBe('ACTIVE');
|
|
expect(result.round).toBe(2);
|
|
expect(result.monster.currentHp).toBe(45 - 14);
|
|
expect(result.player.currentHp).toBe(100 - 5);
|
|
expect(dataSource.state.combats[0].round).toBe(2);
|
|
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
|
|
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
|
|
});
|
|
|
|
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
|
|
const { dataSource, service, combatId } = await startedCombat();
|
|
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
const events = dataSource.state.combatEvents
|
|
.filter((event) => event.combatId === combatId)
|
|
.sort((a, b) => a.sequence - b.sequence);
|
|
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
|
|
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
|
|
expect(events[0]).toMatchObject({
|
|
source: 'PLAYER',
|
|
target: 'MONSTER',
|
|
type: 'DAMAGE',
|
|
amount: 14,
|
|
});
|
|
expect(events[1]).toMatchObject({
|
|
source: 'MONSTER',
|
|
target: 'PLAYER',
|
|
type: 'DAMAGE',
|
|
amount: 5,
|
|
});
|
|
});
|
|
|
|
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
|
const { dataSource, service, combatId } = await startedCombat(state);
|
|
|
|
const result = await service.performAction(
|
|
CHARACTER_ID,
|
|
combatId,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
expect(result.status).toBe('WON');
|
|
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
|
|
await expectCombatDomainError(
|
|
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
|
|
'COMBAT_ALREADY_FINISHED',
|
|
);
|
|
});
|
|
|
|
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
|
const state = createState({
|
|
characters: [character({ baseHp: 1 })],
|
|
});
|
|
const { dataSource, service, combatId } = await startedCombat(state);
|
|
|
|
const result = await service.performAction(
|
|
CHARACTER_ID,
|
|
combatId,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
expect(result.status).toBe('LOST');
|
|
expect(dataSource.state.combats[0].status).toBe(CombatStatus.LOST);
|
|
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
|
|
await expectCombatDomainError(
|
|
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
|
|
'COMBAT_ALREADY_FINISHED',
|
|
);
|
|
});
|
|
|
|
it('rejects actions on an unknown combat id', async () => {
|
|
const { service } = createService();
|
|
|
|
await expectCombatDomainError(
|
|
service.performAction(
|
|
CHARACTER_ID,
|
|
'unknown-combat',
|
|
CombatAction.ATTACK,
|
|
),
|
|
'COMBAT_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
it('rejects actions from a character who does not own the combat', async () => {
|
|
const state = createState({
|
|
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
|
});
|
|
const { service, combatId } = await startedCombat(state);
|
|
|
|
await expectCombatDomainError(
|
|
service.performAction(
|
|
OTHER_CHARACTER_ID,
|
|
combatId,
|
|
CombatAction.ATTACK,
|
|
),
|
|
'COMBAT_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
it('locks the combat row for the duration of the action', async () => {
|
|
const { dataSource, service, combatId } = await startedCombat();
|
|
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
expect(dataSource.locks).toEqual(
|
|
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getCombat', () => {
|
|
it('returns the persisted state and ordered events after a refresh', async () => {
|
|
const context = createService();
|
|
const started = await context.service.startCombat(
|
|
CHARACTER_ID,
|
|
ENCOUNTER_ID,
|
|
);
|
|
await context.service.performAction(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
const reloaded = await context.service.getCombat(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
);
|
|
|
|
expect(reloaded.round).toBe(2);
|
|
expect(reloaded.monster.currentHp).toBe(31);
|
|
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
|
|
});
|
|
|
|
it('rejects an unknown combat id', async () => {
|
|
const { service } = createService();
|
|
|
|
await expectCombatDomainError(
|
|
service.getCombat(CHARACTER_ID, 'unknown'),
|
|
'COMBAT_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
|
|
const context = createService();
|
|
const started = await context.service.startCombat(
|
|
CHARACTER_ID,
|
|
ENCOUNTER_ID,
|
|
);
|
|
await context.service.performAction(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
const active = await context.service.getActiveCombat(CHARACTER_ID);
|
|
|
|
expect(active?.id).toBe(started.id);
|
|
expect(active?.status).toBe('ACTIVE');
|
|
expect(active?.round).toBe(2);
|
|
});
|
|
|
|
it('resolves null when the character has no ACTIVE combat', async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('resolves null once the only combat has finished', async () => {
|
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
|
const context = createService({ state });
|
|
const started = await context.service.startCombat(
|
|
CHARACTER_ID,
|
|
ENCOUNTER_ID,
|
|
);
|
|
await context.service.performAction(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
await expect(
|
|
context.service.getActiveCombat(CHARACTER_ID),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('does not resolve another character ACTIVE combat', async () => {
|
|
const context = createService();
|
|
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
await expect(
|
|
context.service.getActiveCombat(OTHER_CHARACTER_ID),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('keeps returning LOST after the combat has ended', async () => {
|
|
const state = createState({
|
|
characters: [character({ baseHp: 1 })],
|
|
});
|
|
const context = createService({ state });
|
|
const started = await context.service.startCombat(
|
|
CHARACTER_ID,
|
|
ENCOUNTER_ID,
|
|
);
|
|
await context.service.performAction(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
CombatAction.ATTACK,
|
|
);
|
|
|
|
const reloaded = await context.service.getCombat(
|
|
CHARACTER_ID,
|
|
started.id,
|
|
);
|
|
|
|
expect(reloaded.status).toBe('LOST');
|
|
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);
|
|
});
|
|
});
|
|
});
|