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>
This commit is contained in:
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -30,6 +31,7 @@ export interface HuntEncounterDto {
|
||||
id: string;
|
||||
monster: MonsterSummary;
|
||||
dangerRating: DangerRating;
|
||||
status: HuntEncounterStatus;
|
||||
}
|
||||
|
||||
export interface HuntResultDto {
|
||||
@@ -110,28 +112,11 @@ export class HuntingService {
|
||||
huntId: hunt.id,
|
||||
monsterDefinitionId: monster.id,
|
||||
position,
|
||||
status: HuntEncounterStatus.AVAILABLE,
|
||||
});
|
||||
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,
|
||||
});
|
||||
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -142,6 +127,70 @@ export class HuntingService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user