965 lines
31 KiB
TypeScript
965 lines
31 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 { 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';
|
|
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,
|
|
status: HuntEncounterStatus.AVAILABLE,
|
|
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 IN_PROGRESS', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
expect(dataSource.state.huntEncounters[0].status).toBe(
|
|
HuntEncounterStatus.IN_PROGRESS,
|
|
);
|
|
});
|
|
|
|
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-defeated encounter, and does not create a second combat', async () => {
|
|
const state = createState({
|
|
huntEncounters: [
|
|
huntEncounter({ status: HuntEncounterStatus.DEFEATED }),
|
|
],
|
|
});
|
|
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 whose fight is still IN_PROGRESS', async () => {
|
|
const state = createState({
|
|
huntEncounters: [
|
|
huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }),
|
|
],
|
|
});
|
|
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('marks the encounter DEFEATED when the fight is won', async () => {
|
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
|
const { dataSource, service, combatId } = await startedCombat(state);
|
|
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
expect(dataSource.state.huntEncounters[0].status).toBe(
|
|
HuntEncounterStatus.DEFEATED,
|
|
);
|
|
});
|
|
|
|
it('frees the encounter for another attempt when the fight is lost', async () => {
|
|
const state = createState({ characters: [character({ baseHp: 1 })] });
|
|
const { dataSource, service, combatId } = await startedCombat(state);
|
|
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
expect(dataSource.state.huntEncounters[0].status).toBe(
|
|
HuntEncounterStatus.AVAILABLE,
|
|
);
|
|
});
|
|
|
|
it('leaves the encounter IN_PROGRESS while the fight continues', async () => {
|
|
const { dataSource, service, combatId } = await startedCombat();
|
|
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
expect(dataSource.state.huntEncounters[0].status).toBe(
|
|
HuntEncounterStatus.IN_PROGRESS,
|
|
);
|
|
});
|
|
|
|
it('lets a lost encounter be fought again as a fresh combat', async () => {
|
|
const state = createState({ characters: [character({ baseHp: 1 })] });
|
|
const { dataSource, service, combatId } = await startedCombat(state);
|
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
|
|
|
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
|
|
|
expect(retry.id).not.toBe(combatId);
|
|
expect(retry.status).toBe('ACTIVE');
|
|
expect(retry.round).toBe(1);
|
|
expect(retry.player.currentHp).toBe(retry.player.maxHp);
|
|
expect(dataSource.state.combats).toHaveLength(2);
|
|
expect(dataSource.state.huntEncounters[0].status).toBe(
|
|
HuntEncounterStatus.IN_PROGRESS,
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|