Files
ashen-realms/apps/api/src/hunting/hunting.service.ts
Bastian Wagner 3c59603efb feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming
back from a fight saw every encounter as fresh. Encounters now carry their
own status, which the combat module advances as fights start and end.

- hunt_encounters.status replaces consumed_at, which only recorded that a
  fight had begun and could not distinguish a win from a loss
- a lost fight hands the encounter back as AVAILABLE, so it can be retried;
  the unique index tying one combat to one encounter goes with it
- GET /hunts/active serves the resumable hunt, which the hunt page adopts on
  entry rather than trusting its in-memory roll
- defeated encounters are crossed out and lose their hover and attack action
- a fresh page load rejoins a combat the server still holds open

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:34:46 +02:00

250 lines
7.7 KiB
TypeScript

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 { HuntEncounterStatus } from './hunt-encounter-status.enum';
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;
status: HuntEncounterStatus;
}
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,
status: HuntEncounterStatus.AVAILABLE,
});
await txEncounters.save(encounter);
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
}
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounterDtos,
};
});
}
/**
* The hunt the player can still act on, or null if there is none. A hunt
* is only resumable where it was rolled, so travelling away retires it and
* the player has to search the new area instead.
*/
async getActiveHunt(characterId: string): Promise<HuntResultDto | null> {
const characters = this.dataSource.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw characterNotFound();
}
const hunts = this.dataSource.getRepository(Hunt);
const hunt = await hunts.findOne({
where: {
characterId,
status: HuntStatus.ACTIVE,
locationId: character.currentLocationId,
},
});
if (!hunt) {
return null;
}
const huntEncounters = this.dataSource.getRepository(HuntEncounter);
const encounters = await huntEncounters.find({
where: { huntId: hunt.id },
relations: { monster: true },
order: { position: 'ASC' },
});
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounters.map((encounter) =>
this.toEncounterDto(encounter, encounter.monster, character),
),
};
}
private toEncounterDto(
encounter: HuntEncounter,
monster: MonsterDefinition,
character: Character,
): HuntEncounterDto {
return {
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating: calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
),
status: encounter.status,
};
}
/**
* 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,
};
}
}