feat: add HuntingService core domain logic for first-hunt slice

Adds startHunt's server-authoritative flow: complete due travel, verify
the location allows hunting, weighted-random-roll exactly 3 encounters
from the location's enabled monster pool inside a locked transaction
that supersedes any prior active hunt, and compute a danger rating per
encounter from the rolled monster's own stats. Mirrors TravelService's
transaction/locking pattern and error conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-19 12:24:46 +02:00
parent 9423c28bd8
commit 568478dcd2
3 changed files with 807 additions and 0 deletions

View File

@@ -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';

View File

@@ -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<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> }): Promise<T[]> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)),
);
}
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',
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> = {},
): 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> = {},
): 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);
}
});
});

View File

@@ -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<HuntResultDto> {
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<Character>,
characterId: string,
): Promise<Character> {
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,
};
}
}