feat: add HuntingService core domain logic for first-hunt slice
Adds startHunt's server-authoritative flow: complete due travel, verify the location allows hunting, weighted-random-roll exactly 3 encounters from the location's enabled monster pool inside a locked transaction that supersedes any prior active hunt, and compute a danger rating per encounter from the rolled monster's own stats. Mirrors TravelService's transaction/locking pattern and error conventions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
37
apps/api/src/hunting/hunting.errors.ts
Normal file
37
apps/api/src/hunting/hunting.errors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
export class HuntingDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
status: number,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function huntingNotAvailable(): HuntingDomainError {
|
||||
return new HuntingDomainError(
|
||||
'HUNTING_NOT_AVAILABLE',
|
||||
400,
|
||||
'Hunting is not available at the current location.',
|
||||
);
|
||||
}
|
||||
|
||||
export function characterTravelling(): HuntingDomainError {
|
||||
return new HuntingDomainError(
|
||||
'CHARACTER_TRAVELLING',
|
||||
409,
|
||||
'The character cannot hunt while travelling.',
|
||||
);
|
||||
}
|
||||
|
||||
export function noHuntEncountersAvailable(): HuntingDomainError {
|
||||
return new HuntingDomainError(
|
||||
'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
||||
409,
|
||||
'No encounters are currently available at this location.',
|
||||
);
|
||||
}
|
||||
|
||||
export { characterNotFound } from '../travel/travel.errors';
|
||||
570
apps/api/src/hunting/hunting.service.spec.ts
Normal file
570
apps/api/src/hunting/hunting.service.spec.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { EncounterType } from '../monsters/entities/encounter-type.enum';
|
||||
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { TravelService } from '../travel/travel.service';
|
||||
import { TravelStatus } from '../travel/travel-status.enum';
|
||||
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 { HuntStatus } from './hunt-status.enum';
|
||||
import { HuntingDomainError } from './hunting.errors';
|
||||
import { HuntingService } from './hunting.service';
|
||||
import type { RandomSource } from './random-source';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
||||
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
|
||||
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
|
||||
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
|
||||
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
|
||||
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
|
||||
|
||||
interface FakeState {
|
||||
characters: Character[];
|
||||
locationMonsters: LocationMonster[];
|
||||
hunts: Hunt[];
|
||||
huntEncounters: HuntEncounter[];
|
||||
}
|
||||
|
||||
class FakeRepository<T extends { id: string }> {
|
||||
constructor(
|
||||
private readonly state: FakeState,
|
||||
private readonly target: EntityTarget<T>,
|
||||
private readonly inTransaction: boolean,
|
||||
private readonly dataSource: FakeDataSource,
|
||||
) {}
|
||||
|
||||
findOne(options: {
|
||||
where: Partial<T>;
|
||||
lock?: { mode: string };
|
||||
}): Promise<T | null> {
|
||||
if (options.lock) {
|
||||
if (!this.inTransaction) {
|
||||
throw new Error('Pessimistic locks require a transaction');
|
||||
}
|
||||
this.dataSource.locks.push({
|
||||
target: this.target,
|
||||
mode: options.lock.mode,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
this.rows().find((row) => this.matches(row, options.where)) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
find(options: { where: Partial<T> }): Promise<T[]> {
|
||||
return Promise.resolve(
|
||||
this.rows().filter((row) => this.matches(row, options.where)),
|
||||
);
|
||||
}
|
||||
|
||||
create(values: Partial<T>): T {
|
||||
return { ...values } as T;
|
||||
}
|
||||
|
||||
save(entity: T): Promise<T> {
|
||||
if (this.dataSource.failSaveTarget === this.target) {
|
||||
throw new Error(`Failed to save ${this.targetName()}`);
|
||||
}
|
||||
|
||||
if (!entity.id) {
|
||||
entity.id = this.dataSource.nextId(this.targetName());
|
||||
}
|
||||
|
||||
const rows = this.rows();
|
||||
const index = rows.findIndex((row) => row.id === entity.id);
|
||||
if (index === -1) {
|
||||
rows.push(entity);
|
||||
} else {
|
||||
rows[index] = entity;
|
||||
}
|
||||
return Promise.resolve(entity);
|
||||
}
|
||||
|
||||
update(where: Partial<T>, partial: Partial<T>): Promise<void> {
|
||||
for (const row of this.rows()) {
|
||||
if (this.matches(row, where)) {
|
||||
Object.assign(row, partial);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private rows(): T[] {
|
||||
if (this.target === Character) {
|
||||
return this.state.characters as T[];
|
||||
}
|
||||
if (this.target === LocationMonster) {
|
||||
return this.state.locationMonsters as T[];
|
||||
}
|
||||
if (this.target === Hunt) {
|
||||
return this.state.hunts as T[];
|
||||
}
|
||||
if (this.target === HuntEncounter) {
|
||||
return this.state.huntEncounters as T[];
|
||||
}
|
||||
throw new Error(`Unsupported repository ${this.targetName()}`);
|
||||
}
|
||||
|
||||
private matches(row: T, where: Partial<T>): boolean {
|
||||
return Object.entries(where).every(
|
||||
([key, value]) => row[key as keyof T] === value,
|
||||
);
|
||||
}
|
||||
|
||||
private targetName(): string {
|
||||
return typeof this.target === 'function'
|
||||
? this.target.name
|
||||
: 'EntitySchema';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEntityManager {
|
||||
constructor(
|
||||
private readonly state: FakeState,
|
||||
private readonly dataSource: FakeDataSource,
|
||||
) {}
|
||||
|
||||
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||
return new FakeRepository(this.state, target, true, this.dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDataSource {
|
||||
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
|
||||
failSaveTarget?: EntityTarget<unknown>;
|
||||
private readonly idCounters = new Map<string, number>();
|
||||
|
||||
constructor(public state: FakeState) {}
|
||||
|
||||
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||
return new FakeRepository(this.state, target, false, this);
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
work: (manager: EntityManager) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const draft = structuredClone(this.state);
|
||||
const result = await work(
|
||||
new FakeEntityManager(draft, this) as unknown as EntityManager,
|
||||
);
|
||||
this.state = draft;
|
||||
return result;
|
||||
}
|
||||
|
||||
nextId(targetName: string): string {
|
||||
const next = (this.idCounters.get(targetName) ?? 0) + 1;
|
||||
this.idCounters.set(targetName, next);
|
||||
return `${targetName.toLowerCase()}-generated-${next}`;
|
||||
}
|
||||
}
|
||||
|
||||
function huntingLocation(): LocationDefinition {
|
||||
return {
|
||||
id: HUNTING_LOCATION_ID,
|
||||
key: 'burned-road',
|
||||
name: 'Verbrannte Strasse',
|
||||
description: 'A burned road.',
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
maxRecommendedLevel: 2,
|
||||
dangerLevel: 1,
|
||||
isSafe: false,
|
||||
huntingEnabled: true,
|
||||
artworkPath: '/assets/locations/burned-road.webp',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
characters: [],
|
||||
outgoingConnections: [],
|
||||
incomingConnections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function safeLocation(): LocationDefinition {
|
||||
return {
|
||||
id: SAFE_LOCATION_ID,
|
||||
key: 'south-gate',
|
||||
name: 'Suedtor von Graufurt',
|
||||
description: 'A safe gate.',
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
maxRecommendedLevel: 2,
|
||||
dangerLevel: 1,
|
||||
isSafe: true,
|
||||
huntingEnabled: false,
|
||||
artworkPath: '/assets/locations/south-gate.webp',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
characters: [],
|
||||
outgoingConnections: [],
|
||||
incomingConnections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function character(currentLocation: LocationDefinition): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
currentLocationId: currentLocation.id,
|
||||
currentLocation,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
};
|
||||
}
|
||||
|
||||
function monsterDefinition(
|
||||
id: string,
|
||||
key: string,
|
||||
name: string,
|
||||
overrides: Partial<MonsterDefinition> = {},
|
||||
): MonsterDefinition {
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
level: 1,
|
||||
maxHp: 20,
|
||||
attack: 3,
|
||||
armor: 0,
|
||||
experienceReward: 10,
|
||||
silverMin: 1,
|
||||
silverMax: 3,
|
||||
artworkPath: `/assets/monsters/${key}.webp`,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function locationMonster(
|
||||
id: string,
|
||||
locationId: string,
|
||||
monster: MonsterDefinition,
|
||||
weight: number,
|
||||
enabled = true,
|
||||
): LocationMonster {
|
||||
return {
|
||||
id,
|
||||
locationId,
|
||||
monsterId: monster.id,
|
||||
weight,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
enabled,
|
||||
monster,
|
||||
} as LocationMonster;
|
||||
}
|
||||
|
||||
function createState(): FakeState {
|
||||
return {
|
||||
characters: [character(safeLocation())],
|
||||
locationMonsters: [],
|
||||
hunts: [],
|
||||
huntEncounters: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRandomSource(values: number[]): RandomSource {
|
||||
const queue = [...values];
|
||||
return {
|
||||
next: () => {
|
||||
const value = queue.shift();
|
||||
if (value === undefined) {
|
||||
throw new Error('fakeRandomSource exhausted its canned values');
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTravelService(
|
||||
overrides: Partial<TravelService> = {},
|
||||
): TravelService {
|
||||
return {
|
||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||
...overrides,
|
||||
} as unknown as TravelService;
|
||||
}
|
||||
|
||||
function createService(
|
||||
options: {
|
||||
state?: FakeState;
|
||||
travelService?: TravelService;
|
||||
randomSource?: RandomSource;
|
||||
} = {},
|
||||
) {
|
||||
const state = options.state ?? createState();
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const travelService = options.travelService ?? fakeTravelService();
|
||||
const randomSource = options.randomSource ?? fakeRandomSource([]);
|
||||
const service = new HuntingService(
|
||||
dataSource as unknown as DataSource,
|
||||
travelService,
|
||||
randomSource,
|
||||
);
|
||||
return { dataSource, service, travelService, randomSource };
|
||||
}
|
||||
|
||||
async function expectHuntingDomainError(
|
||||
promise: Promise<unknown>,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
let error: unknown;
|
||||
try {
|
||||
await promise;
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
expect(error).toBeInstanceOf(HuntingDomainError);
|
||||
if (!(error instanceof HuntingDomainError)) {
|
||||
throw new Error('Expected HuntingDomainError');
|
||||
}
|
||||
expect(error.code).toBe(code);
|
||||
}
|
||||
|
||||
describe('HuntingService', () => {
|
||||
it('rejects hunting at a location where hunting is disabled', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expectHuntingDomainError(
|
||||
service.startHunt(CHARACTER_ID),
|
||||
'HUNTING_NOT_AVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts a valid hunt with exactly three saved encounters', async () => {
|
||||
const monsterA = monsterDefinition(
|
||||
MONSTER_A_ID,
|
||||
'aschenratte',
|
||||
'Aschenratte',
|
||||
);
|
||||
const monsterB = monsterDefinition(
|
||||
MONSTER_B_ID,
|
||||
'strassenraeuber',
|
||||
'Straßenräuber',
|
||||
);
|
||||
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, 70),
|
||||
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||
];
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(result.encounters).toHaveLength(3);
|
||||
const encounterIds = new Set(result.encounters.map((e) => e.id));
|
||||
expect(encounterIds.size).toBe(3);
|
||||
expect(result.location).toEqual({
|
||||
id: HUNTING_LOCATION_ID,
|
||||
key: 'burned-road',
|
||||
name: 'Verbrannte Strasse',
|
||||
});
|
||||
expect(dataSource.state.hunts).toHaveLength(1);
|
||||
expect(dataSource.state.hunts[0]).toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
locationId: HUNTING_LOCATION_ID,
|
||||
status: HuntStatus.ACTIVE,
|
||||
});
|
||||
expect(dataSource.state.huntEncounters).toHaveLength(3);
|
||||
expect(dataSource.locks).toEqual([
|
||||
{ target: Character, mode: 'pessimistic_write' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects hunting while the character is still travelling', async () => {
|
||||
const travelService = fakeTravelService({
|
||||
completeTravelIfDue: jest.fn().mockResolvedValue({
|
||||
status: TravelStatus.TRAVELLING,
|
||||
originLocation: { id: SAFE_LOCATION_ID, key: 'south-gate', name: 'x' },
|
||||
targetLocation: {
|
||||
id: HUNTING_LOCATION_ID,
|
||||
key: 'burned-road',
|
||||
name: 'y',
|
||||
},
|
||||
startedAt: new Date(),
|
||||
arrivesAt: new Date(),
|
||||
}) as unknown as TravelService['completeTravelIfDue'],
|
||||
});
|
||||
const { service } = createService({ travelService });
|
||||
|
||||
await expectHuntingDomainError(
|
||||
service.startHunt(CHARACTER_ID),
|
||||
'CHARACTER_TRAVELLING',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects hunting when the location has no enabled encounter pool', async () => {
|
||||
const state = createState();
|
||||
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||
state.characters[0].currentLocation = huntingLocation();
|
||||
state.locationMonsters = [];
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectHuntingDomainError(
|
||||
service.startHunt(CHARACTER_ID),
|
||||
'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
it('picks monsters deterministically from canned RandomSource rolls', async () => {
|
||||
const monsterA = monsterDefinition(
|
||||
MONSTER_A_ID,
|
||||
'aschenratte',
|
||||
'Aschenratte',
|
||||
);
|
||||
const monsterB = monsterDefinition(
|
||||
MONSTER_B_ID,
|
||||
'strassenraeuber',
|
||||
'Straßenräuber',
|
||||
);
|
||||
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, 70),
|
||||
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||
];
|
||||
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(result.encounters.map((e) => e.monster.key)).toEqual([
|
||||
'aschenratte',
|
||||
'strassenraeuber',
|
||||
'aschenratte',
|
||||
]);
|
||||
const persisted = [...dataSource.state.huntEncounters].sort(
|
||||
(a, b) => a.position - b.position,
|
||||
);
|
||||
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
|
||||
MONSTER_A_ID,
|
||||
MONSTER_B_ID,
|
||||
MONSTER_A_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it('supersedes the previous active hunt when a new hunt is started', 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, 0.1, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
await service.startHunt(CHARACTER_ID);
|
||||
await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(dataSource.state.hunts).toHaveLength(2);
|
||||
const [firstHunt, secondHunt] = dataSource.state.hunts;
|
||||
expect(firstHunt.status).toBe(HuntStatus.SUPERSEDED);
|
||||
expect(secondHunt.status).toBe(HuntStatus.ACTIVE);
|
||||
expect(firstHunt.id).not.toBe(secondHunt.id);
|
||||
expect(dataSource.locks).toEqual([
|
||||
{ target: Character, mode: 'pessimistic_write' },
|
||||
{ target: Character, mode: 'pessimistic_write' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each encounter its own id matching the monster rolled for that slot', async () => {
|
||||
const monsterA = monsterDefinition(
|
||||
MONSTER_A_ID,
|
||||
'aschenratte',
|
||||
'Aschenratte',
|
||||
);
|
||||
const monsterB = monsterDefinition(
|
||||
MONSTER_B_ID,
|
||||
'strassenraeuber',
|
||||
'Straßenräuber',
|
||||
);
|
||||
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, 70),
|
||||
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||
];
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||
});
|
||||
|
||||
await service.startHunt(CHARACTER_ID);
|
||||
|
||||
const encounters = [...dataSource.state.huntEncounters].sort(
|
||||
(a, b) => a.position - b.position,
|
||||
);
|
||||
const ids = encounters.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID);
|
||||
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID);
|
||||
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID);
|
||||
});
|
||||
|
||||
it('computes a danger rating per encounter from the real monster stats', async () => {
|
||||
const weakMonster = monsterDefinition(
|
||||
MONSTER_A_ID,
|
||||
'aschenratte',
|
||||
'Aschenratte',
|
||||
{
|
||||
attack: 1,
|
||||
armor: 0,
|
||||
maxHp: 5,
|
||||
},
|
||||
);
|
||||
const state = createState();
|
||||
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||
state.characters[0].currentLocation = huntingLocation();
|
||||
state.characters[0].baseAttack = 6;
|
||||
state.characters[0].baseHp = 100;
|
||||
state.locationMonsters = [
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_A_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
weakMonster,
|
||||
100,
|
||||
),
|
||||
];
|
||||
const { service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
for (const encounter of result.encounters) {
|
||||
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
|
||||
}
|
||||
});
|
||||
});
|
||||
200
apps/api/src/hunting/hunting.service.ts
Normal file
200
apps/api/src/hunting/hunting.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: hunt.id,
|
||||
location: this.toLocationSummary(character.currentLocation),
|
||||
encounters: encounterDtos,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user