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 { 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user