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>
250 lines
7.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|