diff --git a/apps/api/src/hunting/hunting.errors.ts b/apps/api/src/hunting/hunting.errors.ts new file mode 100644 index 0000000..c20633d --- /dev/null +++ b/apps/api/src/hunting/hunting.errors.ts @@ -0,0 +1,37 @@ +import { HttpException } from '@nestjs/common'; + +export class HuntingDomainError extends HttpException { + constructor( + public readonly code: string, + status: number, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function huntingNotAvailable(): HuntingDomainError { + return new HuntingDomainError( + 'HUNTING_NOT_AVAILABLE', + 400, + 'Hunting is not available at the current location.', + ); +} + +export function characterTravelling(): HuntingDomainError { + return new HuntingDomainError( + 'CHARACTER_TRAVELLING', + 409, + 'The character cannot hunt while travelling.', + ); +} + +export function noHuntEncountersAvailable(): HuntingDomainError { + return new HuntingDomainError( + 'NO_HUNT_ENCOUNTERS_AVAILABLE', + 409, + 'No encounters are currently available at this location.', + ); +} + +export { characterNotFound } from '../travel/travel.errors'; diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts new file mode 100644 index 0000000..7d9783c --- /dev/null +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -0,0 +1,570 @@ +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 { HuntStatus } from './hunt-status.enum'; +import { HuntingDomainError } from './hunting.errors'; +import { HuntingService } from './hunting.service'; +import type { RandomSource } from './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[]; +} + +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 }): Promise { + return Promise.resolve( + this.rows().filter((row) => this.matches(row, options.where)), + ); + } + + 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', + level: 1, + experience: 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, + experienceReward: 10, + 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); + } + }); +}); diff --git a/apps/api/src/hunting/hunting.service.ts b/apps/api/src/hunting/hunting.service.ts new file mode 100644 index 0000000..55e71b7 --- /dev/null +++ b/apps/api/src/hunting/hunting.service.ts @@ -0,0 +1,200 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import type { LocationSummary } from '../travel/travel.service'; +import { TravelService } from '../travel/travel.service'; +import { TravelStatus } from '../travel/travel-status.enum'; +import { calculateDangerRating, DangerRating } from './danger-rating'; +import { Hunt } from './entities/hunt.entity'; +import { HuntEncounter } from './entities/hunt-encounter.entity'; +import { HuntStatus } from './hunt-status.enum'; +import { + characterNotFound, + characterTravelling, + huntingNotAvailable, + noHuntEncountersAvailable, +} from './hunting.errors'; +import { RANDOM_SOURCE } from './random-source'; +import type { RandomSource } from './random-source'; + +export interface MonsterSummary { + key: string; + name: string; + level: number; + artworkPath: string; +} + +export interface HuntEncounterDto { + id: string; + monster: MonsterSummary; + dangerRating: DangerRating; +} + +export interface HuntResultDto { + id: string; + location: LocationSummary; + encounters: HuntEncounterDto[]; +} + +const ENCOUNTER_COUNT = 3; + +@Injectable() +export class HuntingService { + constructor( + private readonly dataSource: DataSource, + private readonly travelService: TravelService, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + async startHunt(characterId: string): Promise { + const travel = await this.travelService.completeTravelIfDue(characterId); + if (travel.status === TravelStatus.TRAVELLING) { + throw characterTravelling(); + } + + const characters = this.dataSource.getRepository(Character); + const character = await characters.findOne({ + where: { id: characterId }, + relations: { currentLocation: true }, + }); + if (!character) { + // completeTravelIfDue already validated the character exists; this + // guard only protects against a pathological race and satisfies the + // type checker (currentLocation would otherwise be possibly undefined). + throw characterNotFound(); + } + + if (!character.currentLocation.huntingEnabled) { + throw huntingNotAvailable(); + } + + const locationMonsters = this.dataSource.getRepository(LocationMonster); + const pool = await locationMonsters.find({ + where: { locationId: character.currentLocationId, enabled: true }, + relations: { monster: true }, + }); + if (pool.length === 0) { + throw noHuntEncountersAvailable(); + } + + return this.dataSource.transaction(async (manager) => { + const txCharacters = manager.getRepository(Character); + const txHunts = manager.getRepository(Hunt); + const txEncounters = manager.getRepository(HuntEncounter); + + const lockedCharacter = await this.lockCharacter( + txCharacters, + characterId, + ); + + await txHunts.update( + { characterId, status: HuntStatus.ACTIVE }, + { status: HuntStatus.SUPERSEDED }, + ); + + const hunt = txHunts.create({ + characterId, + locationId: lockedCharacter.currentLocationId, + status: HuntStatus.ACTIVE, + }); + await txHunts.save(hunt); + + const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT); + + const encounterDtos: HuntEncounterDto[] = []; + for (let position = 0; position < pickedMonsters.length; position += 1) { + const monster = pickedMonsters[position]; + const encounter = txEncounters.create({ + huntId: hunt.id, + monsterDefinitionId: monster.id, + position, + }); + await txEncounters.save(encounter); + + const dangerRating = calculateDangerRating( + { attack: character.baseAttack, armor: 0, hp: character.baseHp }, + { + attack: monster.attack, + armor: monster.armor, + hp: monster.maxHp, + }, + ); + + encounterDtos.push({ + id: encounter.id, + monster: { + key: monster.key, + name: monster.name, + level: monster.level, + artworkPath: monster.artworkPath, + }, + dangerRating, + }); + } + + return { + id: hunt.id, + location: this.toLocationSummary(character.currentLocation), + encounters: encounterDtos, + }; + }); + } + + /** + * Rolls `count` independent weighted picks from `pool`. Each slot walks + * the pool in the order it was supplied, accumulating weight, and picks + * the first entry whose cumulative weight exceeds the roll + * (roll < cumulative). Pure and deterministic given a RandomSource, so + * it is trivially unit-testable with canned `next()` values. + */ + private rollEncounters( + pool: LocationMonster[], + count: number, + ): MonsterDefinition[] { + const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0); + const picks: MonsterDefinition[] = []; + for (let i = 0; i < count; i += 1) { + const roll = this.randomSource.next() * totalWeight; + let cumulative = 0; + let picked: LocationMonster = pool[pool.length - 1]; + for (const entry of pool) { + cumulative += entry.weight; + if (roll < cumulative) { + picked = entry; + break; + } + } + picks.push(picked.monster); + } + return picks; + } + + private async lockCharacter( + characters: Repository, + characterId: string, + ): Promise { + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + // The pre-transaction load above already confirmed the character + // exists; a miss here would only occur under a pathological + // concurrent deletion, which the schema's RESTRICT FKs prevent. + throw characterNotFound(); + } + return character; + } + + private toLocationSummary( + location: Character['currentLocation'], + ): LocationSummary { + return { + id: location.id, + key: location.key, + name: location.name, + }; + } +}