725 lines
21 KiB
TypeScript
725 lines
21 KiB
TypeScript
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
import { EncounterType } from '../monsters/entities/encounter-type.enum';
|
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
|
import { TravelService } from '../travel/travel.service';
|
|
import { TravelStatus } from '../travel/travel-status.enum';
|
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
|
import { DangerRating } from './danger-rating';
|
|
import { Hunt } from './entities/hunt.entity';
|
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
|
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
|
import { HuntStatus } from './hunt-status.enum';
|
|
import { HuntingDomainError } from './hunting.errors';
|
|
import { HuntingService } from './hunting.service';
|
|
import type { RandomSource } from '../shared/random-source';
|
|
|
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
|
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
|
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
|
|
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
|
|
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
|
|
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
|
|
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
|
|
|
|
interface FakeState {
|
|
characters: Character[];
|
|
locationMonsters: LocationMonster[];
|
|
hunts: Hunt[];
|
|
huntEncounters: HuntEncounter[];
|
|
}
|
|
|
|
// `find` in the fake ignores `relations`, so fixtures attach the joined
|
|
// monster the way TypeORM would have hydrated it.
|
|
function withMonster(
|
|
encounter: HuntEncounter,
|
|
monster: MonsterDefinition,
|
|
): HuntEncounter {
|
|
return { ...encounter, monster };
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
create(values: Partial<T>): T {
|
|
return { ...values } as T;
|
|
}
|
|
|
|
save(entity: T): Promise<T> {
|
|
if (this.dataSource.failSaveTarget === this.target) {
|
|
throw new Error(`Failed to save ${this.targetName()}`);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
update(where: Partial<T>, partial: Partial<T>): Promise<void> {
|
|
for (const row of this.rows()) {
|
|
if (this.matches(row, where)) {
|
|
Object.assign(row, partial);
|
|
}
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
private rows(): T[] {
|
|
if (this.target === Character) {
|
|
return this.state.characters as T[];
|
|
}
|
|
if (this.target === LocationMonster) {
|
|
return this.state.locationMonsters as T[];
|
|
}
|
|
if (this.target === Hunt) {
|
|
return this.state.hunts as T[];
|
|
}
|
|
if (this.target === HuntEncounter) {
|
|
return this.state.huntEncounters 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 }> = [];
|
|
failSaveTarget?: EntityTarget<unknown>;
|
|
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 huntingLocation(): LocationDefinition {
|
|
return {
|
|
id: HUNTING_LOCATION_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Strasse',
|
|
description: 'A burned road.',
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 1,
|
|
maxRecommendedLevel: 2,
|
|
dangerLevel: 1,
|
|
isSafe: false,
|
|
huntingEnabled: true,
|
|
artworkPath: '/assets/locations/burned-road.webp',
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
characters: [],
|
|
outgoingConnections: [],
|
|
incomingConnections: [],
|
|
};
|
|
}
|
|
|
|
function safeLocation(): LocationDefinition {
|
|
return {
|
|
id: SAFE_LOCATION_ID,
|
|
key: 'south-gate',
|
|
name: 'Suedtor von Graufurt',
|
|
description: 'A safe gate.',
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 1,
|
|
maxRecommendedLevel: 2,
|
|
dangerLevel: 1,
|
|
isSafe: true,
|
|
huntingEnabled: false,
|
|
artworkPath: '/assets/locations/south-gate.webp',
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
characters: [],
|
|
outgoingConnections: [],
|
|
incomingConnections: [],
|
|
};
|
|
}
|
|
|
|
function character(currentLocation: LocationDefinition): Character {
|
|
return {
|
|
id: CHARACTER_ID,
|
|
name: 'Aric Duskwalker',
|
|
renown: 1,
|
|
silver: 0,
|
|
baseHp: 100,
|
|
baseAttack: 6,
|
|
currentHp: 100,
|
|
currentLocationId: currentLocation.id,
|
|
currentLocation,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
};
|
|
}
|
|
|
|
function monsterDefinition(
|
|
id: string,
|
|
key: string,
|
|
name: string,
|
|
overrides: Partial<MonsterDefinition> = {},
|
|
): MonsterDefinition {
|
|
return {
|
|
id,
|
|
key,
|
|
name,
|
|
level: 1,
|
|
maxHp: 20,
|
|
attack: 3,
|
|
armor: 0,
|
|
silverMin: 1,
|
|
silverMax: 3,
|
|
artworkPath: `/assets/monsters/${key}.webp`,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function locationMonster(
|
|
id: string,
|
|
locationId: string,
|
|
monster: MonsterDefinition,
|
|
weight: number,
|
|
enabled = true,
|
|
): LocationMonster {
|
|
return {
|
|
id,
|
|
locationId,
|
|
monsterId: monster.id,
|
|
weight,
|
|
encounterType: EncounterType.NORMAL,
|
|
enabled,
|
|
monster,
|
|
} as LocationMonster;
|
|
}
|
|
|
|
function createState(): FakeState {
|
|
return {
|
|
characters: [character(safeLocation())],
|
|
locationMonsters: [],
|
|
hunts: [],
|
|
huntEncounters: [],
|
|
};
|
|
}
|
|
|
|
function fakeRandomSource(values: number[]): RandomSource {
|
|
const queue = [...values];
|
|
return {
|
|
next: () => {
|
|
const value = queue.shift();
|
|
if (value === undefined) {
|
|
throw new Error('fakeRandomSource exhausted its canned values');
|
|
}
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeTravelService(
|
|
overrides: Partial<TravelService> = {},
|
|
): TravelService {
|
|
return {
|
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
|
...overrides,
|
|
} as unknown as TravelService;
|
|
}
|
|
|
|
function createService(
|
|
options: {
|
|
state?: FakeState;
|
|
travelService?: TravelService;
|
|
randomSource?: RandomSource;
|
|
} = {},
|
|
) {
|
|
const state = options.state ?? createState();
|
|
const dataSource = new FakeDataSource(state);
|
|
const travelService = options.travelService ?? fakeTravelService();
|
|
const randomSource = options.randomSource ?? fakeRandomSource([]);
|
|
const service = new HuntingService(
|
|
dataSource as unknown as DataSource,
|
|
travelService,
|
|
randomSource,
|
|
);
|
|
return { dataSource, service, travelService, randomSource };
|
|
}
|
|
|
|
async function expectHuntingDomainError(
|
|
promise: Promise<unknown>,
|
|
code: string,
|
|
): Promise<void> {
|
|
let error: unknown;
|
|
try {
|
|
await promise;
|
|
} catch (cause) {
|
|
error = cause;
|
|
}
|
|
expect(error).toBeInstanceOf(HuntingDomainError);
|
|
if (!(error instanceof HuntingDomainError)) {
|
|
throw new Error('Expected HuntingDomainError');
|
|
}
|
|
expect(error.code).toBe(code);
|
|
}
|
|
|
|
describe('HuntingService', () => {
|
|
it('rejects hunting at a location where hunting is disabled', async () => {
|
|
const { service } = createService();
|
|
|
|
await expectHuntingDomainError(
|
|
service.startHunt(CHARACTER_ID),
|
|
'HUNTING_NOT_AVAILABLE',
|
|
);
|
|
});
|
|
|
|
it('starts a valid hunt with exactly three saved encounters', async () => {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const monsterB = monsterDefinition(
|
|
MONSTER_B_ID,
|
|
'strassenraeuber',
|
|
'Straßenräuber',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [
|
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
|
];
|
|
const { dataSource, service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
|
});
|
|
|
|
const result = await service.startHunt(CHARACTER_ID);
|
|
|
|
expect(result.encounters).toHaveLength(3);
|
|
const encounterIds = new Set(result.encounters.map((e) => e.id));
|
|
expect(encounterIds.size).toBe(3);
|
|
expect(result.location).toEqual({
|
|
id: HUNTING_LOCATION_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Strasse',
|
|
});
|
|
expect(dataSource.state.hunts).toHaveLength(1);
|
|
expect(dataSource.state.hunts[0]).toMatchObject({
|
|
characterId: CHARACTER_ID,
|
|
locationId: HUNTING_LOCATION_ID,
|
|
status: HuntStatus.ACTIVE,
|
|
});
|
|
expect(dataSource.state.huntEncounters).toHaveLength(3);
|
|
expect(dataSource.locks).toEqual([
|
|
{ target: Character, mode: 'pessimistic_write' },
|
|
]);
|
|
});
|
|
|
|
it('rejects hunting while the character is still travelling', async () => {
|
|
const travelService = fakeTravelService({
|
|
completeTravelIfDue: jest.fn().mockResolvedValue({
|
|
status: TravelStatus.TRAVELLING,
|
|
originLocation: { id: SAFE_LOCATION_ID, key: 'south-gate', name: 'x' },
|
|
targetLocation: {
|
|
id: HUNTING_LOCATION_ID,
|
|
key: 'burned-road',
|
|
name: 'y',
|
|
},
|
|
startedAt: new Date(),
|
|
arrivesAt: new Date(),
|
|
}) as unknown as TravelService['completeTravelIfDue'],
|
|
});
|
|
const { service } = createService({ travelService });
|
|
|
|
await expectHuntingDomainError(
|
|
service.startHunt(CHARACTER_ID),
|
|
'CHARACTER_TRAVELLING',
|
|
);
|
|
});
|
|
|
|
it('rejects hunting when the location has no enabled encounter pool', async () => {
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [];
|
|
const { service } = createService({ state });
|
|
|
|
await expectHuntingDomainError(
|
|
service.startHunt(CHARACTER_ID),
|
|
'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
|
);
|
|
});
|
|
|
|
it('picks monsters deterministically from canned RandomSource rolls', async () => {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const monsterB = monsterDefinition(
|
|
MONSTER_B_ID,
|
|
'strassenraeuber',
|
|
'Straßenräuber',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [
|
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
|
];
|
|
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
|
|
const { dataSource, service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
|
});
|
|
|
|
const result = await service.startHunt(CHARACTER_ID);
|
|
|
|
expect(result.encounters.map((e) => e.monster.key)).toEqual([
|
|
'aschenratte',
|
|
'strassenraeuber',
|
|
'aschenratte',
|
|
]);
|
|
const persisted = [...dataSource.state.huntEncounters].sort(
|
|
(a, b) => a.position - b.position,
|
|
);
|
|
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
|
|
MONSTER_A_ID,
|
|
MONSTER_B_ID,
|
|
MONSTER_A_ID,
|
|
]);
|
|
});
|
|
|
|
it('supersedes the previous active hunt when a new hunt is started', async () => {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [
|
|
locationMonster(
|
|
LOCATION_MONSTER_A_ID,
|
|
HUNTING_LOCATION_ID,
|
|
monsterA,
|
|
100,
|
|
),
|
|
];
|
|
const { dataSource, service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
|
|
});
|
|
|
|
await service.startHunt(CHARACTER_ID);
|
|
await service.startHunt(CHARACTER_ID);
|
|
|
|
expect(dataSource.state.hunts).toHaveLength(2);
|
|
const [firstHunt, secondHunt] = dataSource.state.hunts;
|
|
expect(firstHunt.status).toBe(HuntStatus.SUPERSEDED);
|
|
expect(secondHunt.status).toBe(HuntStatus.ACTIVE);
|
|
expect(firstHunt.id).not.toBe(secondHunt.id);
|
|
expect(dataSource.locks).toEqual([
|
|
{ target: Character, mode: 'pessimistic_write' },
|
|
{ target: Character, mode: 'pessimistic_write' },
|
|
]);
|
|
});
|
|
|
|
it('gives each encounter its own id matching the monster rolled for that slot', async () => {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const monsterB = monsterDefinition(
|
|
MONSTER_B_ID,
|
|
'strassenraeuber',
|
|
'Straßenräuber',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [
|
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
|
];
|
|
const { dataSource, service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
|
});
|
|
|
|
await service.startHunt(CHARACTER_ID);
|
|
|
|
const encounters = [...dataSource.state.huntEncounters].sort(
|
|
(a, b) => a.position - b.position,
|
|
);
|
|
const ids = encounters.map((e) => e.id);
|
|
expect(new Set(ids).size).toBe(3);
|
|
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID);
|
|
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID);
|
|
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID);
|
|
});
|
|
|
|
it('computes a danger rating per encounter from the real monster stats', async () => {
|
|
const weakMonster = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
{
|
|
attack: 1,
|
|
armor: 0,
|
|
maxHp: 5,
|
|
},
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.characters[0].baseAttack = 6;
|
|
state.characters[0].baseHp = 100;
|
|
state.locationMonsters = [
|
|
locationMonster(
|
|
LOCATION_MONSTER_A_ID,
|
|
HUNTING_LOCATION_ID,
|
|
weakMonster,
|
|
100,
|
|
),
|
|
];
|
|
const { service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
|
});
|
|
|
|
const result = await service.startHunt(CHARACTER_ID);
|
|
|
|
for (const encounter of result.encounters) {
|
|
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
|
|
}
|
|
});
|
|
|
|
it('marks every freshly rolled encounter as AVAILABLE', async () => {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.locationMonsters = [
|
|
locationMonster(
|
|
LOCATION_MONSTER_A_ID,
|
|
HUNTING_LOCATION_ID,
|
|
monsterA,
|
|
100,
|
|
),
|
|
];
|
|
const { dataSource, service } = createService({
|
|
state,
|
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
|
});
|
|
|
|
const result = await service.startHunt(CHARACTER_ID);
|
|
|
|
expect(result.encounters.map((encounter) => encounter.status)).toEqual([
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.AVAILABLE,
|
|
]);
|
|
expect(
|
|
dataSource.state.huntEncounters.map((encounter) => encounter.status),
|
|
).toEqual([
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.AVAILABLE,
|
|
]);
|
|
});
|
|
|
|
describe('getActiveHunt', () => {
|
|
function activeHuntState(
|
|
statuses: HuntEncounterStatus[],
|
|
overrides: { huntStatus?: HuntStatus; huntLocationId?: string } = {},
|
|
) {
|
|
const monsterA = monsterDefinition(
|
|
MONSTER_A_ID,
|
|
'aschenratte',
|
|
'Aschenratte',
|
|
);
|
|
const state = createState();
|
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
|
state.characters[0].currentLocation = huntingLocation();
|
|
state.hunts = [
|
|
{
|
|
id: 'hunt-1',
|
|
characterId: CHARACTER_ID,
|
|
locationId: overrides.huntLocationId ?? HUNTING_LOCATION_ID,
|
|
status: overrides.huntStatus ?? HuntStatus.ACTIVE,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
} as Hunt,
|
|
];
|
|
state.huntEncounters = statuses.map((status, position) =>
|
|
withMonster(
|
|
{
|
|
id: `encounter-${position}`,
|
|
huntId: 'hunt-1',
|
|
monsterDefinitionId: MONSTER_A_ID,
|
|
position,
|
|
status,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
} as HuntEncounter,
|
|
monsterA,
|
|
),
|
|
);
|
|
return state;
|
|
}
|
|
|
|
it('returns null when the character has no active hunt', async () => {
|
|
const { service } = createService();
|
|
|
|
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null when the only hunt has been superseded', async () => {
|
|
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
|
|
huntStatus: HuntStatus.SUPERSEDED,
|
|
});
|
|
const { service } = createService({ state });
|
|
|
|
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns the active hunt with the persisted status of each encounter', async () => {
|
|
const state = activeHuntState([
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.DEFEATED,
|
|
HuntEncounterStatus.IN_PROGRESS,
|
|
]);
|
|
const { service } = createService({ state });
|
|
|
|
const result = await service.getActiveHunt(CHARACTER_ID);
|
|
|
|
expect(result?.id).toBe('hunt-1');
|
|
expect(result?.location).toEqual({
|
|
id: HUNTING_LOCATION_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Strasse',
|
|
});
|
|
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
|
|
HuntEncounterStatus.AVAILABLE,
|
|
HuntEncounterStatus.DEFEATED,
|
|
HuntEncounterStatus.IN_PROGRESS,
|
|
]);
|
|
expect(result?.encounters.map((encounter) => encounter.id)).toEqual([
|
|
'encounter-0',
|
|
'encounter-1',
|
|
'encounter-2',
|
|
]);
|
|
expect(result?.encounters[0].monster.key).toBe('aschenratte');
|
|
expect(result?.encounters[0].dangerRating).toBeDefined();
|
|
});
|
|
|
|
it('returns null once the character has left the hunt location', async () => {
|
|
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
|
|
huntLocationId: SAFE_LOCATION_ID,
|
|
});
|
|
const { service } = createService({ state });
|
|
|
|
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
|
|
});
|
|
});
|
|
});
|