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:
200
apps/api/src/hunting/hunting.service.ts
Normal file
200
apps/api/src/hunting/hunting.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user