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