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,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,
};
}
}