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:
Bastian Wagner
2026-08-20 10:34:30 +02:00
parent 67237f5ad8
commit 3c59603efb
26 changed files with 869 additions and 66 deletions

View File

@@ -3,6 +3,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
@@ -221,7 +222,7 @@ function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
consumedAt: null,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
@@ -315,12 +316,14 @@ describe('CombatService', () => {
});
});
it('marks the encounter as consumed', async () => {
it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects an unknown encounter id', async () => {
@@ -332,10 +335,25 @@ describe('CombatService', () => {
);
});
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
it('rejects an already-defeated encounter, and does not create a second combat', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
huntEncounter({ status: HuntEncounterStatus.DEFEATED }),
],
});
const { dataSource, service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
);
expect(dataSource.state.combats).toHaveLength(0);
});
it('rejects an encounter whose fight is still IN_PROGRESS', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }),
],
});
const { dataSource, service } = createService({ state });
@@ -518,6 +536,55 @@ describe('CombatService', () => {
);
});
it('marks the encounter DEFEATED when the fight is won', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.DEFEATED,
);
});
it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.AVAILABLE,
);
});
it('leaves the encounter IN_PROGRESS while the fight continues', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('lets a lost encounter be fought again as a fresh combat', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(retry.id).not.toBe(combatId);
expect(retry.status).toBe('ACTIVE');
expect(retry.round).toBe(1);
expect(retry.player.currentHp).toBe(retry.player.maxHp);
expect(dataSource.state.combats).toHaveLength(2);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects actions on an unknown combat id', async () => {
const { service } = createService();

View File

@@ -4,6 +4,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
@@ -93,7 +94,7 @@ export class CombatService {
if (!encounter) {
throw huntEncounterNotFound();
}
if (encounter.consumedAt) {
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
throw huntEncounterAlreadyConsumed();
}
@@ -143,7 +144,7 @@ export class CombatService {
});
await combats.save(combat);
encounter.consumedAt = new Date();
encounter.status = HuntEncounterStatus.IN_PROGRESS;
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
@@ -216,6 +217,11 @@ export class CombatService {
combat.monsterCurrentHp = result.state.monster.currentHp;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
await this.settleEncounter(
manager.getRepository(HuntEncounter),
combat.huntEncounterId,
combat.status,
);
}
await combats.save(combat);
@@ -252,6 +258,28 @@ export class CombatService {
});
}
/**
* Records the fight's outcome on the encounter that spawned it. A win
* retires the encounter; a loss hands it back so the player can try again.
*/
private async settleEncounter(
encounters: Repository<HuntEncounter>,
encounterId: string,
outcome: CombatStatus,
): Promise<void> {
const encounter = await encounters.findOneBy({ id: encounterId });
if (!encounter) {
// combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
encounter.status =
outcome === CombatStatus.WON
? HuntEncounterStatus.DEFEATED
: HuntEncounterStatus.AVAILABLE;
await encounters.save(encounter);
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,

View File

@@ -23,7 +23,9 @@ export interface CombatPlayerState extends CombatCombatantState {
}
@Entity({ name: 'combats' })
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true })
// Deliberately not unique: a lost fight frees the encounter to be retried,
// which creates a second combat row for the same encounter.
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'])
export class Combat {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHuntEncounterStatus1788200000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"hunt_encounter_status_enum\" AS ENUM ('AVAILABLE', 'IN_PROGRESS', 'DEFEATED')",
);
await queryRunner.query(`ALTER TABLE "hunt_encounters"
ADD COLUMN "status" "hunt_encounter_status_enum" NOT NULL DEFAULT 'AVAILABLE'`);
// consumed_at only recorded that a fight had started, so the outcome has
// to be read off the combat it spawned. The unique index this migration
// drops guarantees at most one such combat per encounter.
await queryRunner.query(`UPDATE "hunt_encounters" AS "encounter"
SET "status" = CASE "combat"."status"
WHEN 'WON' THEN 'DEFEATED'::"hunt_encounter_status_enum"
WHEN 'ACTIVE' THEN 'IN_PROGRESS'::"hunt_encounter_status_enum"
ELSE 'AVAILABLE'::"hunt_encounter_status_enum"
END
FROM "combats" AS "combat"
WHERE "combat"."hunt_encounter_id" = "encounter"."id"`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
// A retried encounter gets a second combat row, so the index that kept
// them one-to-one has to go; one ACTIVE combat per character is still
// enforced by IDX_active_combat_per_character.
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(`UPDATE "hunt_encounters"
SET "consumed_at" = now()
WHERE "status" <> 'AVAILABLE'`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "status"',
);
await queryRunner.query('DROP TYPE "hunt_encounter_status_enum"');
}
}

View File

@@ -2,7 +2,6 @@ import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
describe('combat system schema', () => {
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
@@ -28,17 +27,6 @@ describe('combat system schema', () => {
);
});
it('enforces one combat per hunt encounter via a unique index', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) => candidate.target === Combat && candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('enforces ordered, unique event sequencing per combat', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
@@ -52,14 +40,4 @@ describe('combat system schema', () => {
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('adds a nullable consumedAt column to hunt_encounters to prevent reuse', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) => candidate.target === HuntEncounter && candidate.propertyName === 'consumedAt',
);
expect(column).toBeDefined();
expect(column?.options.nullable).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../../hunting/hunt-encounter-status.enum';
describe('encounter status schema', () => {
it('stores the encounter status as a non-nullable enum on hunt_encounters', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'status',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enum).toBe(HuntEncounterStatus);
expect(column?.options.enumName).toBe('hunt_encounter_status_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('drops consumedAt, whose gate the encounter status replaces', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'consumedAt',
);
expect(column).toBeUndefined();
});
it('allows repeated combats per encounter so a lost fight can be retried', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === Combat &&
candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBeFalsy();
});
});

View File

@@ -7,6 +7,7 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { HuntEncounterStatus } from '../hunt-encounter-status.enum';
import { Hunt } from './hunt.entity';
@Entity({ name: 'hunt_encounters' })
@@ -23,10 +24,16 @@ export class HuntEncounter {
@Column({ name: 'position', type: 'integer' })
position!: number;
// Set when a Combat is successfully created from this encounter. Prevents
// one HuntEncounter from spawning more than one Combat (spec §7).
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt!: Date | null;
// Owned by the combat module, which advances it as fights start and end.
// DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the
// encounter to AVAILABLE so the player can try again.
@Column({
name: 'status',
type: 'enum',
enum: HuntEncounterStatus,
enumName: 'hunt_encounter_status_enum',
})
status!: HuntEncounterStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;

View File

@@ -0,0 +1,5 @@
export enum HuntEncounterStatus {
AVAILABLE = 'AVAILABLE',
IN_PROGRESS = 'IN_PROGRESS',
DEFEATED = 'DEFEATED',
}

View File

@@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service';
describe('HuntingController', () => {
let app: INestApplication<App>;
const startHunt = jest.fn();
const getActiveHunt = jest.fn();
beforeEach(async () => {
startHunt.mockReset();
getActiveHunt.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntingController],
providers: [
{
provide: HuntingService,
useValue: { startHunt },
useValue: { startHunt, getActiveHunt },
},
],
}).compile();
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
status: 'DEFEATED',
},
],
};
getActiveHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves an empty body when there is no resumable hunt', async () => {
getActiveHunt.mockResolvedValue(null);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(response.body).toEqual({});
});
});

View File

@@ -1,4 +1,4 @@
import { Controller, Post } from '@nestjs/common';
import { Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntResultDto, HuntingService } from './hunting.service';
@@ -10,4 +10,9 @@ export class HuntingController {
startHunt(): Promise<HuntResultDto> {
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
}
@Get('active')
getActiveHunt(): Promise<HuntResultDto | null> {
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
}
}

View File

@@ -9,6 +9,7 @@ import { LocationDefinition } from '../world/entities/location-definition.entity
import { 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 { HuntingDomainError } from './hunting.errors';
import { HuntingService } from './hunting.service';
@@ -29,6 +30,15 @@ interface FakeState {
huntEncounters: HuntEncounter[];
}
// `find` in the fake ignores `relations`, so fixtures attach the joined
// monster the way TypeORM would have hydrated it.
function withMonster(
encounter: HuntEncounter,
monster: MonsterDefinition,
): HuntEncounter {
return { ...encounter, monster } as HuntEncounter;
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
@@ -56,10 +66,24 @@ class FakeRepository<T extends { id: string }> {
);
}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)),
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
const orderKey = options.order
? (Object.keys(options.order)[0] as keyof T)
: undefined;
if (orderKey) {
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
matched.sort((a, b) => {
if (a[orderKey] === b[orderKey]) return 0;
return a[orderKey] > b[orderKey] ? direction : -direction;
});
}
return Promise.resolve(matched);
}
create(values: Partial<T>): T {
@@ -567,4 +591,135 @@ describe('HuntingService', () => {
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
}
});
it('marks every freshly rolled encounter as AVAILABLE', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
expect(
dataSource.state.huntEncounters.map((encounter) => encounter.status),
).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
});
describe('getActiveHunt', () => {
function activeHuntState(
statuses: HuntEncounterStatus[],
overrides: { huntStatus?: HuntStatus; huntLocationId?: string } = {},
) {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.hunts = [
{
id: 'hunt-1',
characterId: CHARACTER_ID,
locationId: overrides.huntLocationId ?? HUNTING_LOCATION_ID,
status: overrides.huntStatus ?? HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as Hunt,
];
state.huntEncounters = statuses.map((status, position) =>
withMonster(
{
id: `encounter-${position}`,
huntId: 'hunt-1',
monsterDefinitionId: MONSTER_A_ID,
position,
status,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as HuntEncounter,
monsterA,
),
);
return state;
}
it('returns null when the character has no active hunt', async () => {
const { service } = createService();
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns null when the only hunt has been superseded', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntStatus: HuntStatus.SUPERSEDED,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns the active hunt with the persisted status of each encounter', async () => {
const state = activeHuntState([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
const { service } = createService({ state });
const result = await service.getActiveHunt(CHARACTER_ID);
expect(result?.id).toBe('hunt-1');
expect(result?.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
});
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
expect(result?.encounters.map((encounter) => encounter.id)).toEqual([
'encounter-0',
'encounter-1',
'encounter-2',
]);
expect(result?.encounters[0].monster.key).toBe('aschenratte');
expect(result?.encounters[0].dangerRating).toBeDefined();
});
it('returns null once the character has left the hunt location', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntLocationId: SAFE_LOCATION_ID,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
});
});

View File

@@ -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