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 { constructor( private readonly state: FakeState, private readonly target: EntityTarget, private readonly inTransaction: boolean, private readonly dataSource: FakeDataSource, ) {} findOne(options: { where: Partial; lock?: { mode: string }; }): Promise { 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; order?: Partial>; }): Promise { 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 { return { ...values } as T; } save(entity: T): Promise { 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, partial: Partial): Promise { 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): 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(target: EntityTarget) { return new FakeRepository(this.state, target, true, this.dataSource); } } class FakeDataSource { readonly locks: Array<{ target: EntityTarget; mode: string }> = []; failSaveTarget?: EntityTarget; private readonly idCounters = new Map(); constructor(public state: FakeState) {} getRepository(target: EntityTarget) { return new FakeRepository(this.state, target, false, this); } async transaction( work: (manager: EntityManager) => Promise, ): Promise { 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 { 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 { 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, code: string, ): Promise { 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(); }); }); });