Compare commits
51 Commits
f24a5fea9c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31d064d36 | ||
|
|
d174d46fbd | ||
|
|
a87a13fad2 | ||
|
|
3c59603efb | ||
|
|
624c47e5a6 | ||
|
|
048cf80e78 | ||
|
|
b92aecb71a | ||
|
|
1a7a766f2b | ||
|
|
b0e769d0da | ||
|
|
21b377f70c | ||
|
|
6711d51bcd | ||
|
|
cf576133ff | ||
|
|
6448605a72 | ||
|
|
40e830a321 | ||
|
|
2a8883d479 | ||
|
|
d9585b166a | ||
|
|
af665dc677 | ||
|
|
07984110bf | ||
|
|
c58a46b7d9 | ||
|
|
35559e3d2b | ||
|
|
67237f5ad8 | ||
|
|
fe130c597e | ||
|
|
af9d422e8b | ||
|
|
fd9852bfbf | ||
|
|
a5f7772a6d | ||
|
|
f008e53cc6 | ||
|
|
76e8ef4320 | ||
|
|
6c4b7d29d0 | ||
|
|
54f9d59ef4 | ||
|
|
f33b0c0e4a | ||
|
|
e42957d91e | ||
|
|
c390a61594 | ||
|
|
92b9f1cd35 | ||
|
|
dcb248bd15 | ||
|
|
6affb9eddc | ||
|
|
18f30a1ba1 | ||
|
|
aa8c374db0 | ||
|
|
1fd62cddde | ||
|
|
4b1e7f034c | ||
|
|
8cf15afaee | ||
|
|
a6450817de | ||
|
|
bb42195ef5 | ||
|
|
4831dc20b0 | ||
|
|
85184e6e53 | ||
|
|
498349b5eb | ||
|
|
90f6a8cd78 | ||
|
|
bd8a00227f | ||
|
|
edae1af39a | ||
|
|
6cb4d02613 | ||
|
|
47931ff717 | ||
|
|
68edc04ed6 |
17
.claude/launch.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "api",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:api"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:web"],
|
||||
"port": 4200
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CharactersModule } from './characters/characters.module';
|
||||
import { CombatModule } from './combat/combat.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { HuntingModule } from './hunting/hunting.module';
|
||||
@@ -14,6 +15,7 @@ import { WorldModule } from './world/world.module';
|
||||
TravelModule,
|
||||
WorldModule,
|
||||
HuntingModule,
|
||||
CombatModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
describe('CharacterCombatStatsService', () => {
|
||||
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
|
||||
const service = new CharacterCombatStatsService();
|
||||
const character = { baseHp: 100, baseAttack: 6 } as Character;
|
||||
|
||||
expect(service.getStats(character)).toEqual({
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/api/src/characters/character-combat-stats.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface CharacterCombatStats {
|
||||
maxHp: number;
|
||||
attack: number;
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
}
|
||||
|
||||
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
|
||||
// stand in for the starting weapon/armor until Slice 0.5 introduces real
|
||||
// equipment. Replacing them there must not change this method's signature
|
||||
// or the combat API it feeds (spec §10).
|
||||
const TEMPORARY_WEAPON_DAMAGE = 8;
|
||||
const TEMPORARY_ARMOR = 6;
|
||||
|
||||
@Injectable()
|
||||
export class CharacterCombatStatsService {
|
||||
getStats(character: Character): CharacterCombatStats {
|
||||
return {
|
||||
maxHp: character.baseHp,
|
||||
attack: character.baseAttack,
|
||||
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
|
||||
armor: TEMPORARY_ARMOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
||||
import { CharactersController } from './characters.controller';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
@@ -7,6 +8,7 @@ import { Character } from './entities/character.entity';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Character])],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService],
|
||||
providers: [CharactersService, CharacterCombatStatsService],
|
||||
exports: [CharacterCombatStatsService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('CharactersService', () => {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
@@ -30,6 +31,7 @@ describe('CharactersService', () => {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
@@ -45,6 +47,31 @@ describe('CharactersService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 24,
|
||||
silver: 18,
|
||||
currentHp: 100,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'Südtor von Graufurt',
|
||||
},
|
||||
}),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository);
|
||||
|
||||
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a missing demo seed as not found', async () => {
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -26,6 +26,7 @@ export class CharactersService {
|
||||
name: character.name,
|
||||
level: character.level,
|
||||
experience: character.experience,
|
||||
silver: character.silver,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: character.baseHp,
|
||||
attack: character.baseAttack,
|
||||
|
||||
@@ -23,6 +23,9 @@ export class Character {
|
||||
@Column({ name: 'experience', type: 'integer' })
|
||||
experience!: number;
|
||||
|
||||
@Column({ name: 'silver', type: 'integer' })
|
||||
silver!: number;
|
||||
|
||||
@Column({ name: 'base_hp', type: 'integer' })
|
||||
baseHp!: number;
|
||||
|
||||
|
||||
6
apps/api/src/combat/combat-action.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
|
||||
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
|
||||
// CombatEngineService cases — do not add them here until their behavior ships.
|
||||
export enum CombatAction {
|
||||
ATTACK = 'ATTACK',
|
||||
}
|
||||
17
apps/api/src/combat/combat-damage.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { calculateDamage } from './combat-damage';
|
||||
|
||||
describe('calculateDamage', () => {
|
||||
it('applies the established armor mitigation formula', () => {
|
||||
// raw = 12 + 15 = 27; 27 * 60 / (60 + 20) = 20.25 -> rounds to 20
|
||||
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20)).toBe(20);
|
||||
});
|
||||
|
||||
it('never returns less than 1 damage, even against extreme armor', () => {
|
||||
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('treats an attacker with no weaponDamage as having attack alone as its raw damage', () => {
|
||||
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
|
||||
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/combat/combat-damage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface DamageAttacker {
|
||||
attack: number;
|
||||
weaponDamage?: number;
|
||||
}
|
||||
|
||||
const ARMOR_MITIGATION_CONSTANT = 60;
|
||||
|
||||
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number {
|
||||
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
|
||||
const mitigatedDamage =
|
||||
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
|
||||
return Math.max(1, Math.round(mitigatedDamage));
|
||||
}
|
||||
107
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service';
|
||||
import { CombatEngineState } from './combat-engine.types';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { Combatant } from './combatant.enum';
|
||||
|
||||
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
|
||||
return {
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
player: {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
stats: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
},
|
||||
monster: {
|
||||
currentHp: 45,
|
||||
maxHp: 45,
|
||||
stats: { attack: 5, armor: 0 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CombatEngineService', () => {
|
||||
let engine: CombatEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new CombatEngineService();
|
||||
});
|
||||
|
||||
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
|
||||
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
|
||||
|
||||
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated
|
||||
expect(result.state.monster.currentHp).toBe(45 - 14);
|
||||
expect(result.events[0]).toEqual({
|
||||
source: Combatant.PLAYER,
|
||||
target: Combatant.MONSTER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
|
||||
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
|
||||
|
||||
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
|
||||
expect(result.state.player.currentHp).toBe(100 - 5);
|
||||
expect(result.events[1]).toEqual({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: 5,
|
||||
});
|
||||
expect(result.state.status).toBe(CombatStatus.ACTIVE);
|
||||
expect(result.state.round).toBe(2);
|
||||
});
|
||||
|
||||
it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
|
||||
const state = baseState({
|
||||
monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||
|
||||
expect(result.state.monster.currentHp).toBe(0);
|
||||
expect(result.state.status).toBe(CombatStatus.WON);
|
||||
expect(result.state.round).toBe(1);
|
||||
expect(result.events).toEqual([
|
||||
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
|
||||
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
|
||||
const state = baseState({
|
||||
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||
|
||||
expect(result.state.player.currentHp).toBe(0);
|
||||
expect(result.state.status).toBe(CombatStatus.LOST);
|
||||
expect(result.events).toEqual([
|
||||
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
|
||||
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
|
||||
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces the exact same result for the same state and action (determinism)', () => {
|
||||
const state = baseState();
|
||||
|
||||
const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||
const second = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
||||
expect(() =>
|
||||
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
|
||||
).toThrow(UnsupportedCombatActionError);
|
||||
});
|
||||
});
|
||||
83
apps/api/src/combat/combat-engine.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { calculateDamage } from './combat-damage';
|
||||
import { Combatant } from './combatant.enum';
|
||||
import {
|
||||
CombatActionInput,
|
||||
CombatEngineEvent,
|
||||
CombatEngineResult,
|
||||
CombatEngineState,
|
||||
} from './combat-engine.types';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
|
||||
export class UnsupportedCombatActionError extends Error {
|
||||
constructor(action: string) {
|
||||
super(`Unsupported combat action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CombatEngineService {
|
||||
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
||||
switch (input.action) {
|
||||
case CombatAction.ATTACK:
|
||||
return this.resolveAttack(state);
|
||||
default:
|
||||
throw new UnsupportedCombatActionError(input.action);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAttack(state: CombatEngineState): CombatEngineResult {
|
||||
const events: CombatEngineEvent[] = [];
|
||||
const player = { ...state.player };
|
||||
const monster = { ...state.monster };
|
||||
|
||||
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
|
||||
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
|
||||
events.push({
|
||||
source: Combatant.PLAYER,
|
||||
target: Combatant.MONSTER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: playerDamage,
|
||||
});
|
||||
|
||||
if (monster.currentHp <= 0) {
|
||||
events.push({
|
||||
source: Combatant.PLAYER,
|
||||
target: Combatant.MONSTER,
|
||||
type: CombatEventType.COMBAT_WON,
|
||||
});
|
||||
return {
|
||||
state: { ...state, player, monster, status: CombatStatus.WON },
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
|
||||
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: monsterDamage,
|
||||
});
|
||||
|
||||
if (player.currentHp <= 0) {
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.COMBAT_LOST,
|
||||
});
|
||||
return {
|
||||
state: { ...state, player, monster, status: CombatStatus.LOST },
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
|
||||
events,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
apps/api/src/combat/combat-engine.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Combatant } from './combatant.enum';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
|
||||
export interface CombatEngineCombatantStats {
|
||||
attack: number;
|
||||
weaponDamage?: number;
|
||||
armor: number;
|
||||
}
|
||||
|
||||
export interface CombatEngineCombatant {
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
stats: CombatEngineCombatantStats;
|
||||
}
|
||||
|
||||
export interface CombatEngineState {
|
||||
status: CombatStatus;
|
||||
round: number;
|
||||
player: CombatEngineCombatant;
|
||||
monster: CombatEngineCombatant;
|
||||
}
|
||||
|
||||
export interface CombatActionInput {
|
||||
action: CombatAction;
|
||||
}
|
||||
|
||||
export interface CombatEngineEvent {
|
||||
source: Combatant;
|
||||
target: Combatant;
|
||||
type: CombatEventType;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface CombatEngineResult {
|
||||
state: CombatEngineState;
|
||||
events: CombatEngineEvent[];
|
||||
}
|
||||
5
apps/api/src/combat/combat-event-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum CombatEventType {
|
||||
DAMAGE = 'DAMAGE',
|
||||
COMBAT_WON = 'COMBAT_WON',
|
||||
COMBAT_LOST = 'COMBAT_LOST',
|
||||
}
|
||||
5
apps/api/src/combat/combat-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum CombatStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
WON = 'WON',
|
||||
LOST = 'LOST',
|
||||
}
|
||||
97
apps/api/src/combat/combat.controller.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { configureApplication } from '../app.config';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CombatController } from './combat.controller';
|
||||
import { CombatService } from './combat.service';
|
||||
|
||||
describe('CombatController', () => {
|
||||
let app: INestApplication<App>;
|
||||
const getCombat = jest.fn();
|
||||
const getActiveCombat = jest.fn();
|
||||
const performAction = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
getCombat.mockReset();
|
||||
getActiveCombat.mockReset();
|
||||
performAction.mockReset();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CombatController],
|
||||
providers: [
|
||||
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication<App>();
|
||||
configureApplication(app);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
|
||||
getCombat.mockResolvedValue(combat);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
|
||||
|
||||
expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
|
||||
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null };
|
||||
getActiveCombat.mockResolvedValue(combat);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
||||
|
||||
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(getCombat).not.toHaveBeenCalled();
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
|
||||
it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
|
||||
getActiveCombat.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
||||
|
||||
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(response.body).toEqual({});
|
||||
expect(getCombat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null };
|
||||
performAction.mockResolvedValue(combat);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/combats/combat-1/actions')
|
||||
.send({ action: 'ATTACK' })
|
||||
.expect(201);
|
||||
|
||||
expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK');
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
|
||||
it('rejects an unknown action value', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/combats/combat-1/actions')
|
||||
.send({ action: 'HEAVY_STRIKE' })
|
||||
.expect(400);
|
||||
|
||||
expect(performAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects server-owned combat fields the client must never send', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/combats/combat-1/actions')
|
||||
.send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 })
|
||||
.expect(400);
|
||||
|
||||
expect(performAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
25
apps/api/src/combat/combat.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CombatActionDto } from './dto/combat-action.dto';
|
||||
import { CombatService } from './combat.service';
|
||||
|
||||
@Controller('combats')
|
||||
export class CombatController {
|
||||
constructor(private readonly combatService: CombatService) {}
|
||||
|
||||
// Declared before ':combatId' so the literal segment wins the route match.
|
||||
@Get('active')
|
||||
getActiveCombat() {
|
||||
return this.combatService.getActiveCombat(DEMO_CHARACTER_ID);
|
||||
}
|
||||
|
||||
@Get(':combatId')
|
||||
getCombat(@Param('combatId') combatId: string) {
|
||||
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
|
||||
}
|
||||
|
||||
@Post(':combatId/actions')
|
||||
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
|
||||
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
|
||||
}
|
||||
}
|
||||
87
apps/api/src/combat/combat.errors.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type CombatErrorCode =
|
||||
| 'HUNT_ENCOUNTER_NOT_FOUND'
|
||||
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
||||
| 'INVALID_HUNT_ENCOUNTER'
|
||||
| 'CHARACTER_TRAVELLING'
|
||||
| 'COMBAT_ALREADY_ACTIVE'
|
||||
| 'COMBAT_NOT_FOUND'
|
||||
| 'COMBAT_ALREADY_FINISHED'
|
||||
| 'COMBAT_STATE_INVALID';
|
||||
|
||||
export class CombatDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: CombatErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function huntEncounterNotFound(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'HUNT_ENCOUNTER_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This encounter could not be found.',
|
||||
);
|
||||
}
|
||||
|
||||
export function huntEncounterAlreadyConsumed(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
|
||||
HttpStatus.CONFLICT,
|
||||
'This encounter has already been used to start a combat.',
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidHuntEncounter(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'INVALID_HUNT_ENCOUNTER',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'This encounter is not valid for the current character.',
|
||||
);
|
||||
}
|
||||
|
||||
export function characterTravelling(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'CHARACTER_TRAVELLING',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character cannot fight while travelling.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatAlreadyActive(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_ALREADY_ACTIVE',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character already has an active combat.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatNotFound(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This combat could not be found.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatAlreadyFinished(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_ALREADY_FINISHED',
|
||||
HttpStatus.CONFLICT,
|
||||
'This combat has already finished.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatStateInvalid(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_STATE_INVALID',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
'The persisted combat references unavailable data.',
|
||||
);
|
||||
}
|
||||
|
||||
export { characterNotFound } from '../travel/travel.errors';
|
||||
27
apps/api/src/combat/combat.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CharactersModule } from '../characters/characters.module';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { RewardsModule } from '../rewards/rewards.module';
|
||||
import { TravelModule } from '../travel/travel.module';
|
||||
import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatController } from './combat.controller';
|
||||
import { CombatService } from './combat.service';
|
||||
import { Combat } from './entities/combat.entity';
|
||||
import { CombatEvent } from './entities/combat-event.entity';
|
||||
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
|
||||
TravelModule,
|
||||
CharactersModule,
|
||||
RewardsModule,
|
||||
],
|
||||
controllers: [CombatController, HuntEncounterAttackController],
|
||||
providers: [CombatService, CombatEngineService],
|
||||
})
|
||||
export class CombatModule {}
|
||||
964
apps/api/src/combat/combat.service.spec.ts
Normal file
@@ -0,0 +1,964 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||
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 { CombatRewardService } from '../rewards/combat-reward.service';
|
||||
import { TravelService } from '../travel/travel.service';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatDomainError } from './combat.errors';
|
||||
import { CombatService } from './combat.service';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { CombatEvent } from './entities/combat-event.entity';
|
||||
import { Combat } from './entities/combat.entity';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
|
||||
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
|
||||
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
|
||||
|
||||
interface FakeState {
|
||||
characters: Character[];
|
||||
hunts: Hunt[];
|
||||
huntEncounters: HuntEncounter[];
|
||||
monsters: MonsterDefinition[];
|
||||
combats: Combat[];
|
||||
combatEvents: CombatEvent[];
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
findOneBy(where: Partial<T>): Promise<T | null> {
|
||||
return Promise.resolve(
|
||||
this.rows().find((row) => this.matches(row, where)) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
count(options: { where: Partial<T> }): Promise<number> {
|
||||
return Promise.resolve(
|
||||
this.rows().filter((row) => this.matches(row, options.where)).length,
|
||||
);
|
||||
}
|
||||
|
||||
create(values: Partial<T>): T {
|
||||
return { ...values } as T;
|
||||
}
|
||||
|
||||
save(entity: T): Promise<T> {
|
||||
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);
|
||||
}
|
||||
|
||||
private rows(): T[] {
|
||||
if (this.target === Character) return this.state.characters as T[];
|
||||
if (this.target === Hunt) return this.state.hunts as T[];
|
||||
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
|
||||
if (this.target === MonsterDefinition) return this.state.monsters as T[];
|
||||
if (this.target === Combat) return this.state.combats as T[];
|
||||
if (this.target === CombatEvent) return this.state.combatEvents 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 }> = [];
|
||||
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 character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function monster(
|
||||
overrides: Partial<MonsterDefinition> = {},
|
||||
): MonsterDefinition {
|
||||
return {
|
||||
id: MONSTER_ID,
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
attack: 5,
|
||||
armor: 0,
|
||||
experienceReward: 8,
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hunt(overrides: Partial<Hunt> = {}): Hunt {
|
||||
return {
|
||||
id: HUNT_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
locationId: 'location-1',
|
||||
status: HuntStatus.ACTIVE,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Hunt;
|
||||
}
|
||||
|
||||
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
||||
return {
|
||||
id: ENCOUNTER_ID,
|
||||
huntId: HUNT_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
position: 0,
|
||||
status: HuntEncounterStatus.AVAILABLE,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as HuntEncounter;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<FakeState> = {}): FakeState {
|
||||
return {
|
||||
characters: [character()],
|
||||
hunts: [hunt()],
|
||||
huntEncounters: [huntEncounter()],
|
||||
monsters: [monster()],
|
||||
combats: [],
|
||||
combatEvents: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTravelService(
|
||||
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
||||
): TravelService {
|
||||
return {
|
||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
|
||||
} as unknown as TravelService;
|
||||
}
|
||||
|
||||
function fakeRewardService(
|
||||
overrides: Partial<{
|
||||
grantVictoryRewards: jest.Mock;
|
||||
loadRewards: jest.Mock;
|
||||
}> = {},
|
||||
): CombatRewardService {
|
||||
return {
|
||||
grantVictoryRewards:
|
||||
overrides.grantVictoryRewards ??
|
||||
jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [] }),
|
||||
loadRewards: overrides.loadRewards ?? jest.fn().mockResolvedValue(null),
|
||||
} as unknown as CombatRewardService;
|
||||
}
|
||||
|
||||
function createService(
|
||||
options: { state?: FakeState; travelService?: TravelService } = {},
|
||||
) {
|
||||
const state = options.state ?? createState();
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const travelService = options.travelService ?? fakeTravelService();
|
||||
const combatEngine = new CombatEngineService();
|
||||
const characterCombatStats = new CharacterCombatStatsService();
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
travelService,
|
||||
combatEngine,
|
||||
characterCombatStats,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { dataSource, service, travelService };
|
||||
}
|
||||
|
||||
async function expectCombatDomainError(
|
||||
promise: Promise<unknown>,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
let error: unknown;
|
||||
try {
|
||||
await promise;
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
expect(error).toBeInstanceOf(CombatDomainError);
|
||||
if (!(error instanceof CombatDomainError)) {
|
||||
throw new Error('Expected CombatDomainError');
|
||||
}
|
||||
expect(error.code).toBe(code);
|
||||
}
|
||||
|
||||
describe('CombatService', () => {
|
||||
describe('startCombat', () => {
|
||||
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(combat.status).toBe('ACTIVE');
|
||||
expect(combat.round).toBe(1);
|
||||
expect(combat.player).toEqual({
|
||||
name: 'Aric Duskwalker',
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
});
|
||||
expect(combat.monster).toEqual({
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
currentHp: 45,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
});
|
||||
expect(combat.events).toEqual([]);
|
||||
expect(dataSource.state.combats).toHaveLength(1);
|
||||
expect(dataSource.state.combats[0]).toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: ENCOUNTER_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.ACTIVE,
|
||||
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
monsterState: { attack: 5, armor: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the encounter as IN_PROGRESS', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(dataSource.state.huntEncounters[0].status).toBe(
|
||||
HuntEncounterStatus.IN_PROGRESS,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown encounter id', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, 'unknown-id'),
|
||||
'HUNT_ENCOUNTER_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an already-defeated encounter, and does not create a second combat', async () => {
|
||||
const state = createState({
|
||||
huntEncounters: [
|
||||
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 });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
|
||||
);
|
||||
expect(dataSource.state.combats).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an encounter belonging to a different character', async () => {
|
||||
const state = createState({
|
||||
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||
});
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
|
||||
'INVALID_HUNT_ENCOUNTER',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
|
||||
const state = createState({
|
||||
hunts: [hunt({ status: HuntStatus.SUPERSEDED })],
|
||||
});
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'INVALID_HUNT_ENCOUNTER',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects starting combat while the character is travelling', async () => {
|
||||
const { service } = createService({
|
||||
travelService: fakeTravelService('TRAVELLING'),
|
||||
});
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'CHARACTER_TRAVELLING',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects starting a second combat while one is already ACTIVE', async () => {
|
||||
const state = createState({
|
||||
combats: [
|
||||
{
|
||||
id: 'combat-existing',
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: 'other-encounter',
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
playerMaxHp: 100,
|
||||
playerCurrentHp: 100,
|
||||
monsterMaxHp: 45,
|
||||
monsterCurrentHp: 45,
|
||||
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
monsterState: { attack: 5, armor: 0 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: null,
|
||||
} as Combat,
|
||||
],
|
||||
});
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'COMBAT_ALREADY_ACTIVE',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the character and the active-combat lookup', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(dataSource.locks).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ target: Character, mode: 'pessimistic_write' },
|
||||
{ target: Combat, mode: 'pessimistic_write' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performAction', () => {
|
||||
async function startedCombat(state = createState()) {
|
||||
const context = createService({ state });
|
||||
const combat = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
return { ...context, combatId: combat.id };
|
||||
}
|
||||
|
||||
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
const result = await service.performAction(
|
||||
CHARACTER_ID,
|
||||
combatId,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(result.round).toBe(2);
|
||||
expect(result.monster.currentHp).toBe(45 - 14);
|
||||
expect(result.player.currentHp).toBe(100 - 5);
|
||||
expect(dataSource.state.combats[0].round).toBe(2);
|
||||
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
|
||||
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
|
||||
});
|
||||
|
||||
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
const events = dataSource.state.combatEvents
|
||||
.filter((event) => event.combatId === combatId)
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
|
||||
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
|
||||
expect(events[0]).toMatchObject({
|
||||
source: 'PLAYER',
|
||||
target: 'MONSTER',
|
||||
type: 'DAMAGE',
|
||||
amount: 14,
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
source: 'MONSTER',
|
||||
target: 'PLAYER',
|
||||
type: 'DAMAGE',
|
||||
amount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
const result = await service.performAction(
|
||||
CHARACTER_ID,
|
||||
combatId,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('WON');
|
||||
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
|
||||
await expectCombatDomainError(
|
||||
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
|
||||
'COMBAT_ALREADY_FINISHED',
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
});
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
const result = await service.performAction(
|
||||
CHARACTER_ID,
|
||||
combatId,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('LOST');
|
||||
expect(dataSource.state.combats[0].status).toBe(CombatStatus.LOST);
|
||||
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
|
||||
await expectCombatDomainError(
|
||||
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
|
||||
'COMBAT_ALREADY_FINISHED',
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.performAction(
|
||||
CHARACTER_ID,
|
||||
'unknown-combat',
|
||||
CombatAction.ATTACK,
|
||||
),
|
||||
'COMBAT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects actions from a character who does not own the combat', async () => {
|
||||
const state = createState({
|
||||
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||
});
|
||||
const { service, combatId } = await startedCombat(state);
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.performAction(
|
||||
OTHER_CHARACTER_ID,
|
||||
combatId,
|
||||
CombatAction.ATTACK,
|
||||
),
|
||||
'COMBAT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the combat row for the duration of the action', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.locks).toEqual(
|
||||
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCombat', () => {
|
||||
it('returns the persisted state and ordered events after a refresh', async () => {
|
||||
const context = createService();
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
const reloaded = await context.service.getCombat(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
);
|
||||
|
||||
expect(reloaded.round).toBe(2);
|
||||
expect(reloaded.monster.currentHp).toBe(31);
|
||||
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('rejects an unknown combat id', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.getCombat(CHARACTER_ID, 'unknown'),
|
||||
'COMBAT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
|
||||
const context = createService();
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
const active = await context.service.getActiveCombat(CHARACTER_ID);
|
||||
|
||||
expect(active?.id).toBe(started.id);
|
||||
expect(active?.status).toBe('ACTIVE');
|
||||
expect(active?.round).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves null when the character has no ACTIVE combat', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves null once the only combat has finished', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const context = createService({ state });
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
await expect(
|
||||
context.service.getActiveCombat(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve another character ACTIVE combat', async () => {
|
||||
const context = createService();
|
||||
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
await expect(
|
||||
context.service.getActiveCombat(OTHER_CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps returning LOST after the combat has ended', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
});
|
||||
const context = createService({ state });
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
const reloaded = await context.service.getCombat(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
);
|
||||
|
||||
expect(reloaded.status).toBe('LOST');
|
||||
expect(reloaded.player.currentHp).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewards', () => {
|
||||
it('grants rewards inside the same transaction when the round ends in victory', async () => {
|
||||
const dataSource = new FakeDataSource(
|
||||
createState({
|
||||
combats: [
|
||||
{
|
||||
id: 'combat-1',
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: ENCOUNTER_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 3,
|
||||
playerMaxHp: 100,
|
||||
playerCurrentHp: 80,
|
||||
monsterMaxHp: 45,
|
||||
monsterCurrentHp: 1,
|
||||
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
monsterState: { attack: 5, armor: 0 },
|
||||
completedAt: null,
|
||||
} as Combat,
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rewards = fakeRewardService({
|
||||
grantVictoryRewards: jest.fn().mockResolvedValue({
|
||||
experience: 8,
|
||||
silver: 6,
|
||||
items: [],
|
||||
}),
|
||||
});
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
|
||||
|
||||
expect(result.status).toBe(CombatStatus.WON);
|
||||
expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] });
|
||||
expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1);
|
||||
// The reward service must receive the transaction manager, not the data
|
||||
// source: `expect.anything()` would pass even if the code handed over
|
||||
// `this.dataSource`, so assert on the captured argument's identity.
|
||||
const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock
|
||||
.calls[0][0];
|
||||
expect(passedManager).not.toBe(dataSource);
|
||||
expect(rewards.grantVictoryRewards).toHaveBeenCalledWith(
|
||||
passedManager,
|
||||
expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }),
|
||||
);
|
||||
});
|
||||
|
||||
it('grants no rewards when the round ends in defeat', async () => {
|
||||
const dataSource = new FakeDataSource(
|
||||
createState({
|
||||
combats: [
|
||||
{
|
||||
id: 'combat-1',
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: ENCOUNTER_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 3,
|
||||
playerMaxHp: 100,
|
||||
playerCurrentHp: 1,
|
||||
monsterMaxHp: 45,
|
||||
monsterCurrentHp: 45,
|
||||
playerState: { attack: 1, weaponDamage: 1, armor: 0 },
|
||||
monsterState: { attack: 99, armor: 99 },
|
||||
completedAt: null,
|
||||
} as Combat,
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rewards = fakeRewardService();
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
|
||||
|
||||
expect(result.status).toBe(CombatStatus.LOST);
|
||||
expect(result.rewards).toBeNull();
|
||||
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays the persisted reward when a finished combat is read again', async () => {
|
||||
const persisted = {
|
||||
experience: 16,
|
||||
silver: 12,
|
||||
items: [
|
||||
{
|
||||
characterItemId: 'character-item-1',
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
rarity: 'COMMON',
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const dataSource = new FakeDataSource(
|
||||
createState({
|
||||
combats: [
|
||||
{
|
||||
id: 'combat-1',
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: ENCOUNTER_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.WON,
|
||||
round: 5,
|
||||
playerMaxHp: 100,
|
||||
playerCurrentHp: 62,
|
||||
monsterMaxHp: 45,
|
||||
monsterCurrentHp: 0,
|
||||
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
monsterState: { attack: 5, armor: 0 },
|
||||
completedAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||
} as Combat,
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rewards = fakeRewardService({
|
||||
loadRewards: jest.fn().mockResolvedValue(persisted),
|
||||
grantVictoryRewards: jest.fn(),
|
||||
});
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
rewards,
|
||||
);
|
||||
|
||||
const result = await service.getCombat(CHARACTER_ID, 'combat-1');
|
||||
|
||||
expect(result.rewards).toEqual(persisted);
|
||||
// Reading must never grant: only the ACTIVE -> WON transition does.
|
||||
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists nothing at all when reward resolution fails mid-transaction', async () => {
|
||||
const dataSource = new FakeDataSource(
|
||||
createState({
|
||||
combats: [
|
||||
{
|
||||
id: 'combat-1',
|
||||
characterId: CHARACTER_ID,
|
||||
huntEncounterId: ENCOUNTER_ID,
|
||||
monsterDefinitionId: MONSTER_ID,
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 3,
|
||||
playerMaxHp: 100,
|
||||
playerCurrentHp: 80,
|
||||
monsterMaxHp: 45,
|
||||
monsterCurrentHp: 1,
|
||||
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
monsterState: { attack: 5, armor: 0 },
|
||||
completedAt: null,
|
||||
} as Combat,
|
||||
],
|
||||
}),
|
||||
);
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeRewardService({
|
||||
// Genuinely write XP/silver through the transaction's manager
|
||||
// before failing, so the assertions below prove the rollback
|
||||
// discards those writes rather than passing vacuously because
|
||||
// nothing was ever written.
|
||||
grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const combatCharacter = await characters.findOneBy({
|
||||
id: CHARACTER_ID,
|
||||
});
|
||||
if (combatCharacter) {
|
||||
combatCharacter.experience += 8;
|
||||
combatCharacter.silver += 6;
|
||||
await characters.save(combatCharacter);
|
||||
}
|
||||
throw new Error('reward persistence failed');
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK),
|
||||
).rejects.toThrow('reward persistence failed');
|
||||
|
||||
// The whole round rolled back: the combat is still ACTIVE and unmodified,
|
||||
// so no half-granted state can survive.
|
||||
expect(dataSource.state.combats[0].status).toBe(CombatStatus.ACTIVE);
|
||||
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(1);
|
||||
expect(dataSource.state.combatEvents).toHaveLength(0);
|
||||
expect(dataSource.state.characters[0].experience).toBe(0);
|
||||
expect(dataSource.state.characters[0].silver).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
409
apps/api/src/combat/combat.service.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||
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 { CombatRewardService } from '../rewards/combat-reward.service';
|
||||
import type { CombatRewardDto } from '../rewards/combat-reward.service';
|
||||
import { TravelService } from '../travel/travel.service';
|
||||
import { TravelStatus } from '../travel/travel-status.enum';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatEngineState } from './combat-engine.types';
|
||||
import {
|
||||
characterNotFound,
|
||||
characterTravelling,
|
||||
combatAlreadyActive,
|
||||
combatAlreadyFinished,
|
||||
combatNotFound,
|
||||
combatStateInvalid,
|
||||
huntEncounterAlreadyConsumed,
|
||||
huntEncounterNotFound,
|
||||
invalidHuntEncounter,
|
||||
} from './combat.errors';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { CombatEvent } from './entities/combat-event.entity';
|
||||
import { Combat } from './entities/combat.entity';
|
||||
|
||||
export interface CombatPlayerDto {
|
||||
name: string;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
}
|
||||
|
||||
export interface CombatMonsterDto {
|
||||
key: string;
|
||||
name: string;
|
||||
level: number;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
artworkPath: string;
|
||||
}
|
||||
|
||||
export interface CombatEventDto {
|
||||
round: number;
|
||||
sequence: number;
|
||||
type: string;
|
||||
source: string;
|
||||
target: string;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface CombatDto {
|
||||
id: string;
|
||||
status: CombatStatus;
|
||||
round: number;
|
||||
player: CombatPlayerDto;
|
||||
monster: CombatMonsterDto;
|
||||
events: CombatEventDto[];
|
||||
rewards: CombatRewardDto | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CombatService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly travelService: TravelService,
|
||||
private readonly combatEngine: CombatEngineService,
|
||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
||||
private readonly combatRewards: CombatRewardService,
|
||||
) {}
|
||||
|
||||
async startCombat(
|
||||
characterId: string,
|
||||
encounterId: string,
|
||||
): Promise<CombatDto> {
|
||||
const travel = await this.travelService.completeTravelIfDue(characterId);
|
||||
if (travel.status === TravelStatus.TRAVELLING) {
|
||||
throw characterTravelling();
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const encounters = manager.getRepository(HuntEncounter);
|
||||
const hunts = manager.getRepository(Hunt);
|
||||
const monsters = manager.getRepository(MonsterDefinition);
|
||||
const combats = manager.getRepository(Combat);
|
||||
|
||||
const character = await this.lockCharacter(characters, characterId);
|
||||
|
||||
const encounter = await encounters.findOne({
|
||||
where: { id: encounterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!encounter) {
|
||||
throw huntEncounterNotFound();
|
||||
}
|
||||
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
|
||||
throw huntEncounterAlreadyConsumed();
|
||||
}
|
||||
|
||||
const hunt = await hunts.findOneBy({ id: encounter.huntId });
|
||||
if (
|
||||
!hunt ||
|
||||
hunt.characterId !== characterId ||
|
||||
hunt.status !== HuntStatus.ACTIVE
|
||||
) {
|
||||
throw invalidHuntEncounter();
|
||||
}
|
||||
|
||||
const existingActiveCombat = await combats.findOne({
|
||||
where: { characterId, status: CombatStatus.ACTIVE },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (existingActiveCombat) {
|
||||
throw combatAlreadyActive();
|
||||
}
|
||||
|
||||
const monster = await monsters.findOneBy({
|
||||
id: encounter.monsterDefinitionId,
|
||||
});
|
||||
if (!monster) {
|
||||
throw invalidHuntEncounter();
|
||||
}
|
||||
|
||||
const playerStats = this.characterCombatStats.getStats(character);
|
||||
|
||||
const combat = combats.create({
|
||||
characterId,
|
||||
huntEncounterId: encounter.id,
|
||||
monsterDefinitionId: monster.id,
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
playerMaxHp: playerStats.maxHp,
|
||||
playerCurrentHp: playerStats.maxHp,
|
||||
monsterMaxHp: monster.maxHp,
|
||||
monsterCurrentHp: monster.maxHp,
|
||||
playerState: {
|
||||
attack: playerStats.attack,
|
||||
weaponDamage: playerStats.weaponDamage,
|
||||
armor: playerStats.armor,
|
||||
},
|
||||
monsterState: { attack: monster.attack, armor: monster.armor },
|
||||
completedAt: null,
|
||||
});
|
||||
await combats.save(combat);
|
||||
|
||||
encounter.status = HuntEncounterStatus.IN_PROGRESS;
|
||||
await encounters.save(encounter);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, [], null);
|
||||
});
|
||||
}
|
||||
|
||||
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
|
||||
const combats = this.dataSource.getRepository(Combat);
|
||||
const combat = await combats.findOne({
|
||||
where: { id: combatId, characterId },
|
||||
});
|
||||
if (!combat) {
|
||||
throw combatNotFound();
|
||||
}
|
||||
|
||||
const [character, monster, events, rewards] = await Promise.all([
|
||||
this.loadCharacter(combat.characterId),
|
||||
this.loadMonster(combat.monsterDefinitionId),
|
||||
this.loadEvents(combat.id),
|
||||
this.combatRewards.loadRewards(combat.id),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||
}
|
||||
|
||||
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
||||
const combats = this.dataSource.getRepository(Combat);
|
||||
const combat = await combats.findOne({
|
||||
where: { characterId, status: CombatStatus.ACTIVE },
|
||||
});
|
||||
if (!combat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [character, monster, events] = await Promise.all([
|
||||
this.loadCharacter(combat.characterId),
|
||||
this.loadMonster(combat.monsterDefinitionId),
|
||||
this.loadEvents(combat.id),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events, null);
|
||||
}
|
||||
|
||||
async performAction(
|
||||
characterId: string,
|
||||
combatId: string,
|
||||
action: CombatAction,
|
||||
): Promise<CombatDto> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const combats = manager.getRepository(Combat);
|
||||
const combatEvents = manager.getRepository(CombatEvent);
|
||||
|
||||
// Lock the character before the combat row here, matching the order
|
||||
// startCombat already uses (character, then combat). grantVictoryRewards
|
||||
// locks the character again later in this same transaction, which is a
|
||||
// no-op re-lock — but locking it first here keeps both code paths
|
||||
// consistent and avoids a lock-order inversion that could deadlock two
|
||||
// concurrent requests against the same character. Do not reorder this.
|
||||
await this.lockCharacter(characters, characterId);
|
||||
|
||||
const combat = await combats.findOne({
|
||||
where: { id: combatId, characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!combat) {
|
||||
throw combatNotFound();
|
||||
}
|
||||
if (combat.status !== CombatStatus.ACTIVE) {
|
||||
throw combatAlreadyFinished();
|
||||
}
|
||||
|
||||
const actionRound = combat.round;
|
||||
const engineState = this.toEngineState(combat);
|
||||
const result = this.combatEngine.resolveAction(engineState, { action });
|
||||
|
||||
combat.round = result.state.round;
|
||||
combat.status = result.state.status;
|
||||
combat.playerCurrentHp = result.state.player.currentHp;
|
||||
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);
|
||||
|
||||
const startingSequence = await combatEvents.count({
|
||||
where: { combatId: combat.id },
|
||||
});
|
||||
for (let index = 0; index < result.events.length; index += 1) {
|
||||
const event = result.events[index];
|
||||
const entity = combatEvents.create({
|
||||
combatId: combat.id,
|
||||
round: actionRound,
|
||||
sequence: startingSequence + index + 1,
|
||||
type: event.type,
|
||||
source: event.source,
|
||||
target: event.target,
|
||||
amount: event.amount ?? null,
|
||||
});
|
||||
await combatEvents.save(entity);
|
||||
}
|
||||
|
||||
// The engine decided the outcome; rewards are resolved here, outside it
|
||||
// (spec §30). Running inside this transaction means a reward failure
|
||||
// rolls the whole round back rather than leaving a half-granted victory.
|
||||
const rewards =
|
||||
combat.status === CombatStatus.WON
|
||||
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
||||
: null;
|
||||
|
||||
const [character, monster, events] = await Promise.all([
|
||||
this.loadCharacter(
|
||||
combat.characterId,
|
||||
manager.getRepository(Character),
|
||||
),
|
||||
this.loadMonster(
|
||||
combat.monsterDefinitionId,
|
||||
manager.getRepository(MonsterDefinition),
|
||||
),
|
||||
this.loadEvents(combat.id, combatEvents),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Promise<Character> {
|
||||
const character = await characters.findOne({
|
||||
where: { id: characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw characterNotFound();
|
||||
}
|
||||
return character;
|
||||
}
|
||||
|
||||
private async loadCharacter(
|
||||
characterId: string,
|
||||
repo?: Repository<Character>,
|
||||
): Promise<Character> {
|
||||
const characters = repo ?? this.dataSource.getRepository(Character);
|
||||
const character = await characters.findOneBy({ id: characterId });
|
||||
if (!character) {
|
||||
// combats.character_id is a RESTRICT FK; a persisted combat's
|
||||
// character is guaranteed to exist.
|
||||
throw combatStateInvalid();
|
||||
}
|
||||
return character;
|
||||
}
|
||||
|
||||
private async loadMonster(
|
||||
monsterId: string,
|
||||
repo?: Repository<MonsterDefinition>,
|
||||
): Promise<MonsterDefinition> {
|
||||
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
|
||||
const monster = await monsters.findOneBy({ id: monsterId });
|
||||
if (!monster) {
|
||||
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
|
||||
throw combatStateInvalid();
|
||||
}
|
||||
return monster;
|
||||
}
|
||||
|
||||
private loadEvents(
|
||||
combatId: string,
|
||||
repo?: Repository<CombatEvent>,
|
||||
): Promise<CombatEvent[]> {
|
||||
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
|
||||
return combatEvents.find({
|
||||
where: { combatId },
|
||||
order: { sequence: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
private toEngineState(combat: Combat): CombatEngineState {
|
||||
return {
|
||||
status: combat.status,
|
||||
round: combat.round,
|
||||
player: {
|
||||
currentHp: combat.playerCurrentHp,
|
||||
maxHp: combat.playerMaxHp,
|
||||
stats: combat.playerState,
|
||||
},
|
||||
monster: {
|
||||
currentHp: combat.monsterCurrentHp,
|
||||
maxHp: combat.monsterMaxHp,
|
||||
stats: combat.monsterState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toCombatDto(
|
||||
combat: Combat,
|
||||
playerName: string,
|
||||
monster: MonsterDefinition,
|
||||
events: CombatEvent[],
|
||||
rewards: CombatRewardDto | null,
|
||||
): CombatDto {
|
||||
return {
|
||||
id: combat.id,
|
||||
status: combat.status,
|
||||
round: combat.round,
|
||||
player: {
|
||||
name: playerName,
|
||||
maxHp: combat.playerMaxHp,
|
||||
currentHp: combat.playerCurrentHp,
|
||||
},
|
||||
monster: {
|
||||
key: monster.key,
|
||||
name: monster.name,
|
||||
level: monster.level,
|
||||
maxHp: combat.monsterMaxHp,
|
||||
currentHp: combat.monsterCurrentHp,
|
||||
artworkPath: monster.artworkPath,
|
||||
},
|
||||
events: events.map((event) => ({
|
||||
round: event.round,
|
||||
sequence: event.sequence,
|
||||
type: event.type,
|
||||
source: event.source,
|
||||
target: event.target,
|
||||
amount: event.amount ?? undefined,
|
||||
})),
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
}
|
||||
4
apps/api/src/combat/combatant.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum Combatant {
|
||||
PLAYER = 'PLAYER',
|
||||
MONSTER = 'MONSTER',
|
||||
}
|
||||
7
apps/api/src/combat/dto/combat-action.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { CombatAction } from '../combat-action.enum';
|
||||
|
||||
export class CombatActionDto {
|
||||
@IsEnum(CombatAction)
|
||||
action!: CombatAction;
|
||||
}
|
||||
62
apps/api/src/combat/entities/combat-event.entity.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Combatant } from '../combatant.enum';
|
||||
import { CombatEventType } from '../combat-event-type.enum';
|
||||
import { Combat } from './combat.entity';
|
||||
|
||||
@Entity({ name: 'combat_events' })
|
||||
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
|
||||
export class CombatEvent {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'combat_id', type: 'uuid' })
|
||||
combatId!: string;
|
||||
|
||||
@Column({ name: 'round', type: 'integer' })
|
||||
round!: number;
|
||||
|
||||
@Column({ name: 'sequence', type: 'integer' })
|
||||
sequence!: number;
|
||||
|
||||
@Column({
|
||||
name: 'type',
|
||||
type: 'enum',
|
||||
enum: CombatEventType,
|
||||
enumName: 'combat_event_type_enum',
|
||||
})
|
||||
type!: CombatEventType;
|
||||
|
||||
@Column({
|
||||
name: 'source',
|
||||
type: 'enum',
|
||||
enum: Combatant,
|
||||
enumName: 'combatant_enum',
|
||||
})
|
||||
source!: Combatant;
|
||||
|
||||
@Column({
|
||||
name: 'target',
|
||||
type: 'enum',
|
||||
enum: Combatant,
|
||||
enumName: 'combatant_enum',
|
||||
})
|
||||
target!: Combatant;
|
||||
|
||||
@Column({ name: 'amount', type: 'integer', nullable: true })
|
||||
amount!: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'combat_id' })
|
||||
combat!: Combat;
|
||||
}
|
||||
91
apps/api/src/combat/entities/combat.entity.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { CombatStatus } from '../combat-status.enum';
|
||||
|
||||
export interface CombatCombatantState {
|
||||
attack: number;
|
||||
armor: number;
|
||||
}
|
||||
|
||||
export interface CombatPlayerState extends CombatCombatantState {
|
||||
weaponDamage: number;
|
||||
}
|
||||
|
||||
@Entity({ name: 'combats' })
|
||||
// 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;
|
||||
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({ name: 'hunt_encounter_id', type: 'uuid' })
|
||||
huntEncounterId!: string;
|
||||
|
||||
@Column({ name: 'monster_definition_id', type: 'uuid' })
|
||||
monsterDefinitionId!: string;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: CombatStatus,
|
||||
enumName: 'combat_status_enum',
|
||||
})
|
||||
status!: CombatStatus;
|
||||
|
||||
@Column({ name: 'round', type: 'integer' })
|
||||
round!: number;
|
||||
|
||||
@Column({ name: 'player_max_hp', type: 'integer' })
|
||||
playerMaxHp!: number;
|
||||
|
||||
@Column({ name: 'player_current_hp', type: 'integer' })
|
||||
playerCurrentHp!: number;
|
||||
|
||||
@Column({ name: 'monster_max_hp', type: 'integer' })
|
||||
monsterMaxHp!: number;
|
||||
|
||||
@Column({ name: 'monster_current_hp', type: 'integer' })
|
||||
monsterCurrentHp!: number;
|
||||
|
||||
@Column({ name: 'player_state', type: 'jsonb' })
|
||||
playerState!: CombatPlayerState;
|
||||
|
||||
@Column({ name: 'monster_state', type: 'jsonb' })
|
||||
monsterState!: CombatCombatantState;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt!: Date | null;
|
||||
|
||||
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
|
||||
@ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'hunt_encounter_id' })
|
||||
huntEncounter!: HuntEncounter;
|
||||
|
||||
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'monster_definition_id' })
|
||||
monster!: MonsterDefinition;
|
||||
}
|
||||
41
apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { configureApplication } from '../app.config';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CombatService } from './combat.service';
|
||||
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
|
||||
|
||||
describe('HuntEncounterAttackController', () => {
|
||||
let app: INestApplication<App>;
|
||||
const startCombat = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
startCombat.mockReset();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [HuntEncounterAttackController],
|
||||
providers: [{ provide: CombatService, useValue: { startCombat } }],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication<App>();
|
||||
configureApplication(app);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
|
||||
startCombat.mockResolvedValue(combat);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/hunt-encounters/encounter-1/attack')
|
||||
.expect(201);
|
||||
|
||||
expect(startCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'encounter-1');
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/combat/hunt-encounter-attack.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Controller, Param, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CombatService } from './combat.service';
|
||||
|
||||
@Controller('hunt-encounters')
|
||||
export class HuntEncounterAttackController {
|
||||
constructor(private readonly combatService: CombatService) {}
|
||||
|
||||
@Post(':encounterId/attack')
|
||||
attack(@Param('encounterId') encounterId: string) {
|
||||
return this.combatService.startCombat(DEMO_CHARACTER_ID, encounterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateCombatSystem1788100000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"combat_status_enum\" AS ENUM ('ACTIVE', 'WON', 'LOST')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"combat_event_type_enum\" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"combatant_enum\" AS ENUM ('PLAYER', 'MONSTER')",
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combats" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"hunt_encounter_id" uuid NOT NULL,
|
||||
"monster_definition_id" uuid NOT NULL,
|
||||
"status" "combat_status_enum" NOT NULL,
|
||||
"round" integer NOT NULL,
|
||||
"player_max_hp" integer NOT NULL,
|
||||
"player_current_hp" integer NOT NULL,
|
||||
"monster_max_hp" integer NOT NULL,
|
||||
"monster_current_hp" integer NOT NULL,
|
||||
"player_state" jsonb NOT NULL,
|
||||
"monster_state" jsonb NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"completed_at" TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT "PK_combats" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_combats_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combats_hunt_encounter" FOREIGN KEY ("hunt_encounter_id") REFERENCES "hunt_encounters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combats_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combats_character" ON "combats" ("character_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combats_monster_definition" ON "combats" ("monster_definition_id")',
|
||||
);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_combat_per_character"
|
||||
ON "combats" ("character_id")
|
||||
WHERE "status" = 'ACTIVE'`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combat_events" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"combat_id" uuid NOT NULL,
|
||||
"round" integer NOT NULL,
|
||||
"sequence" integer NOT NULL,
|
||||
"type" "combat_event_type_enum" NOT NULL,
|
||||
"source" "combatant_enum" NOT NULL,
|
||||
"target" "combatant_enum" NOT NULL,
|
||||
"amount" integer,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_combat_events" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_combat_events_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combat_events_combat" ON "combat_events" ("combat_id")',
|
||||
);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_combat_events_combat_sequence"
|
||||
ON "combat_events" ("combat_id", "sequence")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_events_combat_sequence"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_events_combat"');
|
||||
await queryRunner.query('DROP TABLE "combat_events"');
|
||||
await queryRunner.query('DROP INDEX "IDX_active_combat_per_character"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combats_monster_definition"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combats_character"');
|
||||
await queryRunner.query('DROP TABLE "combats"');
|
||||
await queryRunner.query('DROP TYPE "combatant_enum"');
|
||||
await queryRunner.query('DROP TYPE "combat_event_type_enum"');
|
||||
await queryRunner.query('DROP TYPE "combat_status_enum"');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateLootAndRewards1788600000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Existing characters keep their progression; silver simply starts at 0.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')",
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "item_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"type" "item_type_enum" NOT NULL,
|
||||
"equipment_slot" "equipment_slot_enum",
|
||||
"rarity" "item_rarity_enum" NOT NULL,
|
||||
"tier" integer NOT NULL,
|
||||
"required_level" integer NOT NULL,
|
||||
"weapon_damage" integer NOT NULL DEFAULT 0,
|
||||
"bonus_hp" integer NOT NULL DEFAULT 0,
|
||||
"bonus_attack" integer NOT NULL DEFAULT 0,
|
||||
"bonus_armor" integer NOT NULL DEFAULT 0,
|
||||
"sell_price" integer NOT NULL DEFAULT 0,
|
||||
"icon_path" character varying(255) NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "loot_tables" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "loot_table_entries" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"loot_table_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"drop_chance" numeric(5,4) NOT NULL,
|
||||
"min_quantity" integer NOT NULL DEFAULT 1,
|
||||
"max_quantity" integer NOT NULL DEFAULT 1,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1),
|
||||
CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"),
|
||||
CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "character_items" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"quantity" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_items" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1),
|
||||
CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combat_rewards" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"combat_id" uuid NOT NULL,
|
||||
"character_id" uuid NOT NULL,
|
||||
"experience_granted" integer NOT NULL,
|
||||
"silver_granted" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
// The database half of the "one reward per combat" invariant (spec §7).
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "combat_reward_items" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"combat_reward_id" uuid NOT NULL,
|
||||
"character_item_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"quantity" integer NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1),
|
||||
CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"');
|
||||
await queryRunner.query('DROP TABLE "combat_reward_items"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"');
|
||||
await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"');
|
||||
await queryRunner.query('DROP TABLE "combat_rewards"');
|
||||
await queryRunner.query('DROP INDEX "IDX_character_items_character_item"');
|
||||
await queryRunner.query('DROP TABLE "character_items"');
|
||||
await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"',
|
||||
);
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"');
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"');
|
||||
await queryRunner.query('DROP TABLE "loot_table_entries"');
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
|
||||
await queryRunner.query('DROP TABLE "loot_tables"');
|
||||
await queryRunner.query('DROP INDEX "IDX_item_definitions_key"');
|
||||
await queryRunner.query('DROP TABLE "item_definitions"');
|
||||
await queryRunner.query('DROP TYPE "item_rarity_enum"');
|
||||
await queryRunner.query('DROP TYPE "equipment_slot_enum"');
|
||||
await queryRunner.query('DROP TYPE "item_type_enum"');
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Combat } from '../../combat/entities/combat.entity';
|
||||
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
||||
|
||||
describe('combat system schema', () => {
|
||||
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
|
||||
const relations = metadata.relations.filter(
|
||||
(relation) => relation.target === Combat || relation.target === CombatEvent,
|
||||
);
|
||||
|
||||
expect(
|
||||
relations.map((relation) => ({
|
||||
onDelete: relation.options.onDelete,
|
||||
propertyName: relation.propertyName,
|
||||
target: relation.target,
|
||||
})),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces ordered, unique event sequencing per combat', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const index = metadata.indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === CombatEvent &&
|
||||
candidate.columns?.includes('combatId') &&
|
||||
candidate.columns?.includes('sequence'),
|
||||
);
|
||||
|
||||
expect(index).toBeDefined();
|
||||
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
|
||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
|
||||
|
||||
function uniqueIndexFor(target: unknown, columns: string[]) {
|
||||
const index = getMetadataArgsStorage().indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === target &&
|
||||
columns.every((column) => candidate.columns?.includes(column)),
|
||||
);
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
return indexMetadata?.options?.unique ?? indexMetadata?.unique;
|
||||
}
|
||||
|
||||
describe('loot and rewards schema', () => {
|
||||
it('gives every combat at most one reward record', () => {
|
||||
expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps one stack per character per item definition', () => {
|
||||
expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps loot-table content keys and entry positions unique', () => {
|
||||
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true);
|
||||
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true);
|
||||
});
|
||||
|
||||
it('maps reward and loot relations with the documented onDelete behavior', () => {
|
||||
const relations = getMetadataArgsStorage().relations.filter((relation) =>
|
||||
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes(
|
||||
relation.target as never,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
relations.map((relation) => ({
|
||||
onDelete: relation.options.onDelete,
|
||||
propertyName: relation.propertyName,
|
||||
target: relation.target,
|
||||
})),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }),
|
||||
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }),
|
||||
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the character silver column and the nullable monster loot table link', () => {
|
||||
const columns = getMetadataArgsStorage().columns;
|
||||
|
||||
const silver = columns.find(
|
||||
(candidate) => candidate.target === Character && candidate.propertyName === 'silver',
|
||||
);
|
||||
expect(silver).toBeDefined();
|
||||
expect(silver?.options.type).toBe('integer');
|
||||
|
||||
const lootTableId = columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId',
|
||||
);
|
||||
expect(lootTableId).toBeDefined();
|
||||
expect(lootTableId?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('stores drop chance as a numeric column so probabilities stay data-driven', () => {
|
||||
const dropChance = getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance',
|
||||
);
|
||||
|
||||
expect(dropChance?.options.type).toBe('numeric');
|
||||
expect(dropChance?.options.precision).toBe(5);
|
||||
expect(dropChance?.options.scale).toBe(4);
|
||||
});
|
||||
|
||||
it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => {
|
||||
// synchronize: false means entity decorators never touch the real database -
|
||||
// only the raw SQL emitted by the migration itself does. Assert on that SQL
|
||||
// directly so deleting a constraint here would fail this test.
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateLootAndRewards1788600000000();
|
||||
|
||||
await migration.up(queryRunner);
|
||||
|
||||
const upQueries = query.mock.calls.map(([sql]) => sql as string);
|
||||
|
||||
expect(upQueries).toEqual(
|
||||
expect.arrayContaining([
|
||||
// The database half of the "one reward per combat" invariant (spec §7, §37).
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'),
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'),
|
||||
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'),
|
||||
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'),
|
||||
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'),
|
||||
]),
|
||||
);
|
||||
|
||||
const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK ('));
|
||||
expect(checkConstraints.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
checkConstraints.some(
|
||||
(sql) =>
|
||||
sql.includes('CHK_loot_table_entries_drop_chance') ||
|
||||
sql.includes('CHK_character_items_quantity'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await migration.down(queryRunner);
|
||||
|
||||
const downQueries = query.mock.calls
|
||||
.slice(upQueries.length)
|
||||
.map(([sql]) => sql as string);
|
||||
|
||||
// Proves down() is real and reverses the up() migration, not a no-op.
|
||||
expect(downQueries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('DROP TABLE "combat_rewards"'),
|
||||
expect.stringContaining('DROP TABLE "character_items"'),
|
||||
'ALTER TABLE "characters" DROP COLUMN "silver"',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
216
apps/api/src/database/seeds/item-content.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../../items/item-rarity.enum';
|
||||
import { ItemType } from '../../items/item-type.enum';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ItemKey,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
|
||||
export interface SeedItemDefinition {
|
||||
id: string;
|
||||
key: ItemKey;
|
||||
name: string;
|
||||
description: string;
|
||||
type: ItemType;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
rarity: ItemRarity;
|
||||
tier: number;
|
||||
requiredLevel: number;
|
||||
weaponDamage: number;
|
||||
bonusHp: number;
|
||||
bonusAttack: number;
|
||||
bonusArmor: number;
|
||||
sellPrice: number;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
function item(
|
||||
key: ItemKey,
|
||||
name: string,
|
||||
description: string,
|
||||
type: ItemType,
|
||||
equipmentSlot: EquipmentSlot | null,
|
||||
rarity: ItemRarity,
|
||||
stats: Partial<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
|
||||
): SeedItemDefinition {
|
||||
return {
|
||||
id: ITEM_IDS[key],
|
||||
key,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
equipmentSlot,
|
||||
rarity,
|
||||
tier: 1,
|
||||
requiredLevel: 1,
|
||||
weaponDamage: stats.weaponDamage ?? 0,
|
||||
bonusHp: stats.bonusHp ?? 0,
|
||||
bonusAttack: stats.bonusAttack ?? 0,
|
||||
bonusArmor: stats.bonusArmor ?? 0,
|
||||
// Always 0: no merchants exist in Slice 0.4, and the balancing doc's
|
||||
// Grenzmarken table lists purchase prices, not sell prices.
|
||||
sellPrice: 0,
|
||||
iconPath: `/images/items/${key}.png`,
|
||||
};
|
||||
}
|
||||
|
||||
// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19.
|
||||
export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
|
||||
item(
|
||||
'worn-short-sword',
|
||||
'Abgenutztes Kurzschwert',
|
||||
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 8 },
|
||||
),
|
||||
item(
|
||||
'bandit-blade',
|
||||
'Räuberklinge',
|
||||
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 11, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'ash-blade',
|
||||
'Aschenklinge',
|
||||
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
|
||||
ItemType.WEAPON,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.RARE,
|
||||
{ weaponDamage: 15, bonusAttack: 2 },
|
||||
),
|
||||
item(
|
||||
'bandit-hood',
|
||||
'Räuberhaube',
|
||||
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.HEAD,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'reinforced-leather-jacket',
|
||||
'Verstärkte Lederjacke',
|
||||
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.CHEST,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 7, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'raider-gloves',
|
||||
'Plündererhandschuhe',
|
||||
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.HANDS,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'guardsman-legs',
|
||||
'Wachmannsbeinkleid',
|
||||
'Beinzeug der Grenzwacht, an den Knien geflickt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.LEGS,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 5, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'ash-boots',
|
||||
'Aschenstiefel',
|
||||
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.FEET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 4, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'borderwatch-sigil',
|
||||
'Zeichen der Grenzwacht',
|
||||
'Das Wappen eines Turms, den es nicht mehr gibt.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusAttack: 3, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'burned-captain-pendant',
|
||||
'Anhänger des verbrannten Hauptmanns',
|
||||
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
|
||||
ItemType.ARMOR,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.EPIC,
|
||||
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
|
||||
),
|
||||
// Seeded as content only. Slice 0.4 implements no consumable use, and the
|
||||
// Straßenräuber loot entry for it is deliberately deferred (spec §16).
|
||||
item(
|
||||
'small-healing-potion',
|
||||
'Kleiner Heiltrank',
|
||||
'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.',
|
||||
ItemType.CONSUMABLE,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
item(
|
||||
'ash-pelt',
|
||||
'Aschenfell',
|
||||
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
|
||||
ItemType.MATERIAL,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
];
|
||||
|
||||
export const LOOT_TABLES = [
|
||||
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' },
|
||||
{ id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' },
|
||||
];
|
||||
|
||||
export interface SeedLootTableEntry {
|
||||
lootTableId: string;
|
||||
itemDefinitionId: string;
|
||||
position: number;
|
||||
dropChance: string;
|
||||
minQuantity: number;
|
||||
maxQuantity: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function entry(
|
||||
lootTableId: string,
|
||||
key: ItemKey,
|
||||
position: number,
|
||||
dropChance: string,
|
||||
): SeedLootTableEntry {
|
||||
return {
|
||||
lootTableId,
|
||||
itemDefinitionId: ITEM_IDS[key],
|
||||
position,
|
||||
dropChance,
|
||||
minQuantity: 1,
|
||||
maxQuantity: 1,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28.
|
||||
* Every entry is an independent roll (spec §17), rolled in `position` order.
|
||||
*
|
||||
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is
|
||||
* omitted here because Slice 0.4 implements no consumables (spec §16).
|
||||
*/
|
||||
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
|
||||
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
|
||||
];
|
||||
20
apps/api/src/database/seeds/item.constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Stable content ids, in art-sheet order. Aschenfell (no sheet entry) is last.
|
||||
export const ITEM_IDS = {
|
||||
'worn-short-sword': '50000000-0000-4000-8000-000000000001',
|
||||
'bandit-blade': '50000000-0000-4000-8000-000000000002',
|
||||
'ash-blade': '50000000-0000-4000-8000-000000000003',
|
||||
'bandit-hood': '50000000-0000-4000-8000-000000000004',
|
||||
'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005',
|
||||
'raider-gloves': '50000000-0000-4000-8000-000000000006',
|
||||
'guardsman-legs': '50000000-0000-4000-8000-000000000007',
|
||||
'ash-boots': '50000000-0000-4000-8000-000000000008',
|
||||
'borderwatch-sigil': '50000000-0000-4000-8000-000000000009',
|
||||
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
|
||||
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
|
||||
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
|
||||
} as const;
|
||||
|
||||
export type ItemKey = keyof typeof ITEM_IDS;
|
||||
|
||||
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
|
||||
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
|
||||
@@ -1,9 +1,13 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -67,24 +71,20 @@ function createDataSource(
|
||||
characterRepository: InMemoryRepository,
|
||||
monsterRepository: InMemoryRepository,
|
||||
locationMonsterRepository: InMemoryRepository,
|
||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === LocationDefinition) {
|
||||
return locationRepository;
|
||||
}
|
||||
if (entity === LocationConnection) {
|
||||
return connectionRepository;
|
||||
}
|
||||
if (entity === Character) {
|
||||
return characterRepository;
|
||||
}
|
||||
if (entity === MonsterDefinition) {
|
||||
return monsterRepository;
|
||||
}
|
||||
if (entity === LocationMonster) {
|
||||
return locationMonsterRepository;
|
||||
}
|
||||
if (entity === LocationDefinition) return locationRepository;
|
||||
if (entity === LocationConnection) return connectionRepository;
|
||||
if (entity === Character) return characterRepository;
|
||||
if (entity === MonsterDefinition) return monsterRepository;
|
||||
if (entity === LocationMonster) return locationMonsterRepository;
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -256,4 +256,84 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const itemRepository = new InMemoryRepository();
|
||||
const lootTableRepository = new InMemoryRepository();
|
||||
const lootEntryRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
itemRepository,
|
||||
lootTableRepository,
|
||||
lootEntryRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(itemRepository.rows).toHaveLength(12);
|
||||
expect(itemRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
type: 'WEAPON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
rarity: 'COMMON',
|
||||
weaponDamage: 11,
|
||||
bonusAttack: 1,
|
||||
sellPrice: 0,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'ash-pelt',
|
||||
name: 'Aschenfell',
|
||||
type: 'MATERIAL',
|
||||
equipmentSlot: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(lootTableRepository.rows).toHaveLength(2);
|
||||
expect(lootEntryRepository.rows).toHaveLength(5);
|
||||
expect(lootEntryRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['ash-pelt'],
|
||||
position: 1,
|
||||
dropChance: '0.6000',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||
position: 1,
|
||||
dropChance: '0.1800',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// The Kleiner Heiltrank entry is deliberately deferred (spec §16).
|
||||
expect(
|
||||
lootEntryRepository.rows.some(
|
||||
(row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'],
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(monsterRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }),
|
||||
expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.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 { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||
import {
|
||||
ASH_RAT_MONSTER_ID,
|
||||
BURNED_ROAD_ID,
|
||||
@@ -21,6 +29,9 @@ export async function seedVisibleVerticalSlice(
|
||||
const characterRepository = dataSource.getRepository(Character);
|
||||
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
||||
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
||||
const itemRepository = dataSource.getRepository(ItemDefinition);
|
||||
const lootTableRepository = dataSource.getRepository(LootTable);
|
||||
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
||||
|
||||
const locations = [
|
||||
{
|
||||
@@ -95,6 +106,15 @@ export async function seedVisibleVerticalSlice(
|
||||
['fromLocationId', 'toLocationId'],
|
||||
);
|
||||
|
||||
// Content is upserted by its stable key so re-running never duplicates rows
|
||||
// and never touches player-owned character_items or combat_rewards.
|
||||
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
|
||||
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
|
||||
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
|
||||
'lootTableId',
|
||||
'itemDefinitionId',
|
||||
]);
|
||||
|
||||
const monsters = [
|
||||
{
|
||||
id: ASH_RAT_MONSTER_ID,
|
||||
@@ -108,6 +128,7 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||
},
|
||||
{
|
||||
id: ROAD_BANDIT_MONSTER_ID,
|
||||
@@ -121,6 +142,7 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
},
|
||||
];
|
||||
let ashRatId = ASH_RAT_MONSTER_ID;
|
||||
@@ -176,6 +198,7 @@ export async function seedVisibleVerticalSlice(
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
|
||||
@@ -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,6 +24,17 @@ export class HuntEncounter {
|
||||
@Column({ name: 'position', type: 'integer' })
|
||||
position!: number;
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum HuntEncounterStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
DEFEATED = 'DEFEATED',
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Hunt } from './entities/hunt.entity';
|
||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||
import { HuntingController } from './hunting.controller';
|
||||
import { HuntingService } from './hunting.service';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from './random-source';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -9,10 +9,11 @@ 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';
|
||||
import type { RandomSource } from './random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
||||
@@ -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 {
|
||||
@@ -211,6 +235,7 @@ function character(currentLocation: LocationDefinition): Character {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
@@ -567,4 +592,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -16,8 +17,8 @@ import {
|
||||
huntingNotAvailable,
|
||||
noHuntEncountersAvailable,
|
||||
} from './hunting.errors';
|
||||
import { RANDOM_SOURCE } from './random-source';
|
||||
import type { RandomSource } from './random-source';
|
||||
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
|
||||
export interface MonsterSummary {
|
||||
key: string;
|
||||
@@ -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
|
||||
|
||||
51
apps/api/src/items/entities/character-item.entity.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { ItemDefinition } from './item-definition.entity';
|
||||
|
||||
/**
|
||||
* One stack of one item definition owned by one character.
|
||||
*
|
||||
* Duplicate drops increment `quantity` (spec §28 allows duplicates and forbids
|
||||
* duplicate protection). Slice 0.5 equips a `CharacterItem.id`, never an
|
||||
* `ItemDefinition.id`.
|
||||
*/
|
||||
@Entity({ name: 'character_items' })
|
||||
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class CharacterItem {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||
itemDefinitionId!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'integer' })
|
||||
quantity!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'item_definition_id' })
|
||||
itemDefinition!: ItemDefinition;
|
||||
}
|
||||
74
apps/api/src/items/entities/item-definition.entity.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { EquipmentSlot } from '../equipment-slot.enum';
|
||||
import { ItemRarity } from '../item-rarity.enum';
|
||||
import { ItemType } from '../item-type.enum';
|
||||
|
||||
@Entity({ name: 'item_definitions' })
|
||||
@Index('IDX_item_definitions_key', ['key'], { unique: true })
|
||||
export class ItemDefinition {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' })
|
||||
type!: ItemType;
|
||||
|
||||
@Column({
|
||||
name: 'equipment_slot',
|
||||
type: 'enum',
|
||||
enum: EquipmentSlot,
|
||||
enumName: 'equipment_slot_enum',
|
||||
nullable: true,
|
||||
})
|
||||
equipmentSlot!: EquipmentSlot | null;
|
||||
|
||||
@Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' })
|
||||
rarity!: ItemRarity;
|
||||
|
||||
@Column({ name: 'tier', type: 'integer' })
|
||||
tier!: number;
|
||||
|
||||
@Column({ name: 'required_level', type: 'integer' })
|
||||
requiredLevel!: number;
|
||||
|
||||
@Column({ name: 'weapon_damage', type: 'integer' })
|
||||
weaponDamage!: number;
|
||||
|
||||
@Column({ name: 'bonus_hp', type: 'integer' })
|
||||
bonusHp!: number;
|
||||
|
||||
@Column({ name: 'bonus_attack', type: 'integer' })
|
||||
bonusAttack!: number;
|
||||
|
||||
@Column({ name: 'bonus_armor', type: 'integer' })
|
||||
bonusArmor!: number;
|
||||
|
||||
// Always 0 in Slice 0.4: there are no merchants, and the balancing doc's
|
||||
// Grenzmarken table lists purchase prices, not sell prices.
|
||||
@Column({ name: 'sell_price', type: 'integer' })
|
||||
sellPrice!: number;
|
||||
|
||||
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||
iconPath!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
10
apps/api/src/items/equipment-slot.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Slice 0.4 only stores the slot as content data. Slice 0.5 makes it functional.
|
||||
export enum EquipmentSlot {
|
||||
WEAPON = 'WEAPON',
|
||||
HEAD = 'HEAD',
|
||||
CHEST = 'CHEST',
|
||||
HANDS = 'HANDS',
|
||||
LEGS = 'LEGS',
|
||||
FEET = 'FEET',
|
||||
AMULET = 'AMULET',
|
||||
}
|
||||
7
apps/api/src/items/item-rarity.enum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Mirrors docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19:
|
||||
// Gewöhnlich / Selten / Episch. German labels live in the frontend.
|
||||
export enum ItemRarity {
|
||||
COMMON = 'COMMON',
|
||||
RARE = 'RARE',
|
||||
EPIC = 'EPIC',
|
||||
}
|
||||
6
apps/api/src/items/item-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ItemType {
|
||||
WEAPON = 'WEAPON',
|
||||
ARMOR = 'ARMOR',
|
||||
MATERIAL = 'MATERIAL',
|
||||
CONSUMABLE = 'CONSUMABLE',
|
||||
}
|
||||
63
apps/api/src/loot/entities/loot-table-entry.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTable } from './loot-table.entity';
|
||||
|
||||
/**
|
||||
* One independently rolled drop (spec §17).
|
||||
*
|
||||
* `position` fixes the roll order so injected randoms are predictable in tests;
|
||||
* it is content ordering, not priority. A guaranteed drop is simply
|
||||
* `dropChance = 1.0000` — no extra mechanism needed (spec §14).
|
||||
*/
|
||||
@Entity({ name: 'loot_table_entries' })
|
||||
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true })
|
||||
@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true })
|
||||
export class LootTableEntry {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'loot_table_id', type: 'uuid' })
|
||||
lootTableId!: string;
|
||||
|
||||
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||
itemDefinitionId!: string;
|
||||
|
||||
@Column({ name: 'position', type: 'integer' })
|
||||
position!: number;
|
||||
|
||||
// PostgreSQL numeric arrives as a string, like LocationConnection.ambushChance.
|
||||
@Column({ name: 'drop_chance', type: 'numeric', precision: 5, scale: 4 })
|
||||
dropChance!: string;
|
||||
|
||||
@Column({ name: 'min_quantity', type: 'integer' })
|
||||
minQuantity!: number;
|
||||
|
||||
@Column({ name: 'max_quantity', type: 'integer' })
|
||||
maxQuantity!: number;
|
||||
|
||||
@Column({ name: 'enabled', type: 'boolean' })
|
||||
enabled!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => LootTable, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'loot_table_id' })
|
||||
lootTable!: LootTable;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'item_definition_id' })
|
||||
itemDefinition!: ItemDefinition;
|
||||
}
|
||||
27
apps/api/src/loot/entities/loot-table.entity.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'loot_tables' })
|
||||
@Index('IDX_loot_tables_key', ['key'], { unique: true })
|
||||
export class LootTable {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
13
apps/api/src/loot/loot.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||
import { LootTable } from './entities/loot-table.entity';
|
||||
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||
import { LootService } from './loot.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])],
|
||||
providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }],
|
||||
exports: [LootService],
|
||||
})
|
||||
export class LootModule {}
|
||||
128
apps/api/src/loot/loot.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||
import { LootService } from './loot.service';
|
||||
|
||||
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
|
||||
const ASH_PELT = '50000000-0000-4000-8000-00000000000c';
|
||||
const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001';
|
||||
|
||||
function entry(overrides: Partial<LootTableEntry>): LootTableEntry {
|
||||
return {
|
||||
id: 'entry-1',
|
||||
lootTableId: ASH_RAT_TABLE,
|
||||
itemDefinitionId: ASH_PELT,
|
||||
position: 1,
|
||||
dropChance: '0.6000',
|
||||
minQuantity: 1,
|
||||
maxQuantity: 1,
|
||||
enabled: true,
|
||||
createdAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||
...overrides,
|
||||
} as LootTableEntry;
|
||||
}
|
||||
|
||||
// Hands out the queued values in order, so a test states exactly which roll
|
||||
// each value answers.
|
||||
function queuedRandom(...values: number[]): RandomSource {
|
||||
let index = 0;
|
||||
return {
|
||||
next: () => {
|
||||
if (index >= values.length) {
|
||||
throw new Error('LootService consumed more random values than the test queued');
|
||||
}
|
||||
return values[index++];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dataSourceWith(entries: LootTableEntry[]): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn(() => ({
|
||||
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) =>
|
||||
entries
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.lootTableId === options.where.lootTableId &&
|
||||
candidate.enabled === options.where.enabled,
|
||||
)
|
||||
.sort((a, b) => a.position - b.position),
|
||||
),
|
||||
})),
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
|
||||
describe('LootService', () => {
|
||||
const ashRatEntries = [
|
||||
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }),
|
||||
entry({
|
||||
id: 'entry-sword',
|
||||
itemDefinitionId: WORN_SHORT_SWORD,
|
||||
position: 2,
|
||||
dropChance: '0.0800',
|
||||
}),
|
||||
];
|
||||
|
||||
it('drops an entry when the roll falls under its chance', async () => {
|
||||
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07));
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||
items: [
|
||||
{ itemDefinitionId: ASH_PELT, quantity: 1 },
|
||||
{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an entry when the roll lands on or above its chance', async () => {
|
||||
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08));
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('rolls each entry independently, so one combat can drop only the second item', async () => {
|
||||
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01));
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls entries in position order so injected values stay predictable', async () => {
|
||||
const outOfOrder = [
|
||||
entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }),
|
||||
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }),
|
||||
];
|
||||
const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5));
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores disabled entries', async () => {
|
||||
const service = new LootService(
|
||||
dataSourceWith([entry({ dropChance: '1.0000', enabled: false })]),
|
||||
queuedRandom(),
|
||||
);
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('consumes no quantity roll when min and max match, and one when they differ', async () => {
|
||||
const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })];
|
||||
// First value drops the entry, second picks the quantity (0.5 -> 3).
|
||||
const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5));
|
||||
|
||||
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||
items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing for a monster without a loot table', async () => {
|
||||
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom());
|
||||
|
||||
await expect(service.rollLoot(null)).resolves.toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
66
apps/api/src/loot/loot.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { rollInclusive } from '../shared/roll-range';
|
||||
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||
|
||||
export interface LootRollItem {
|
||||
itemDefinitionId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface LootRollResult {
|
||||
items: LootRollItem[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LootService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rolls a loot table without persisting anything (spec §20).
|
||||
*
|
||||
* Every enabled entry is one independent roll in `position` order, so a
|
||||
* single combat may yield nothing, one item, or several (spec §17). The
|
||||
* quantity roll is skipped entirely when `minQuantity === maxQuantity`,
|
||||
* which keeps the random sequence stable for the seeded content.
|
||||
*/
|
||||
async rollLoot(
|
||||
lootTableId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<LootRollResult> {
|
||||
if (!lootTableId) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const entries = await (
|
||||
manager?.getRepository(LootTableEntry) ??
|
||||
this.dataSource.getRepository(LootTableEntry)
|
||||
).find({
|
||||
where: { lootTableId, enabled: true },
|
||||
order: { position: 'ASC' },
|
||||
});
|
||||
|
||||
const items: LootRollItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (this.randomSource.next() >= Number(entry.dropChance)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
itemDefinitionId: entry.itemDefinitionId,
|
||||
quantity: rollInclusive(
|
||||
this.randomSource,
|
||||
entry.minQuantity,
|
||||
entry.maxQuantity,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
|
||||
@Entity({ name: 'monster_definitions' })
|
||||
@Index('IDX_monster_definitions_key', ['key'], { unique: true })
|
||||
@@ -43,9 +46,16 @@ export class MonsterDefinition {
|
||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||
artworkPath!: string;
|
||||
|
||||
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
|
||||
lootTableId!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true })
|
||||
@JoinColumn({ name: 'loot_table_id' })
|
||||
lootTable!: LootTable | null;
|
||||
}
|
||||
|
||||
445
apps/api/src/rewards/combat-reward.service.spec.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { EntityManager, EntityTarget } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
import { ItemType } from '../items/item-type.enum';
|
||||
import { LootService } from '../loot/loot.service';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { CombatRewardService } from './combat-reward.service';
|
||||
import { CombatReward } from './entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
import { RewardDomainError } from './rewards.errors';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const COMBAT_ID = '20000000-0000-4000-8000-000000000001';
|
||||
const ASH_RAT_ID = '30000000-0000-4000-8000-000000000001';
|
||||
const ROAD_BANDIT_ID = '30000000-0000-4000-8000-000000000002';
|
||||
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
|
||||
const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002';
|
||||
const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002';
|
||||
const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001';
|
||||
|
||||
interface State {
|
||||
characters: Character[];
|
||||
monsters: MonsterDefinition[];
|
||||
itemDefinitions: ItemDefinition[];
|
||||
characterItems: CharacterItem[];
|
||||
combatRewards: CombatReward[];
|
||||
combatRewardItems: CombatRewardItem[];
|
||||
}
|
||||
|
||||
class FakeRepository<T extends { id: string }> {
|
||||
constructor(
|
||||
private readonly rows: T[],
|
||||
private readonly prefix: string,
|
||||
) {}
|
||||
|
||||
findOne(options: { where: Partial<T> }): Promise<T | null> {
|
||||
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
|
||||
}
|
||||
|
||||
findOneBy(where: Partial<T>): Promise<T | null> {
|
||||
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
|
||||
}
|
||||
|
||||
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));
|
||||
if (!options.order) {
|
||||
return Promise.resolve(matched);
|
||||
}
|
||||
|
||||
// Mirrors TypeORM's `order` clause so tests can prove ordering comes from
|
||||
// the query, not from insertion order happening to line up.
|
||||
const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC'];
|
||||
const sorted = [...matched].sort((a, b) => {
|
||||
const left = a[key];
|
||||
const right = b[key];
|
||||
const comparison = left < right ? -1 : left > right ? 1 : 0;
|
||||
return direction === 'DESC' ? -comparison : comparison;
|
||||
});
|
||||
return Promise.resolve(sorted);
|
||||
}
|
||||
|
||||
create(values: Partial<T>): T {
|
||||
return { ...values } as T;
|
||||
}
|
||||
|
||||
save(entity: T): Promise<T> {
|
||||
if (!entity.id) {
|
||||
entity.id = `${this.prefix}-${this.rows.length + 1}`;
|
||||
}
|
||||
const index = this.rows.findIndex((row) => row.id === entity.id);
|
||||
if (index === -1) {
|
||||
this.rows.push(entity);
|
||||
} else {
|
||||
this.rows[index] = entity;
|
||||
}
|
||||
return Promise.resolve(entity);
|
||||
}
|
||||
|
||||
private matches(row: T, where: Partial<T>): boolean {
|
||||
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeManager(state: State): EntityManager {
|
||||
return {
|
||||
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
|
||||
if (target === Character) return new FakeRepository(state.characters, 'character') as never;
|
||||
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never;
|
||||
if (target === ItemDefinition)
|
||||
return new FakeRepository(state.itemDefinitions, 'definition') as never;
|
||||
if (target === CharacterItem)
|
||||
return new FakeRepository(state.characterItems, 'character-item') as never;
|
||||
if (target === CombatReward)
|
||||
return new FakeRepository(state.combatRewards, 'reward') as never;
|
||||
if (target === CombatRewardItem)
|
||||
return new FakeRepository(state.combatRewardItems, 'reward-item') as never;
|
||||
throw new Error('Unsupported repository');
|
||||
},
|
||||
} as unknown as EntityManager;
|
||||
}
|
||||
|
||||
function combat(overrides: Partial<Combat> = {}): Combat {
|
||||
return {
|
||||
id: COMBAT_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
monsterDefinitionId: ASH_RAT_ID,
|
||||
status: CombatStatus.WON,
|
||||
round: 4,
|
||||
...overrides,
|
||||
} as Combat;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<State> = {}): State {
|
||||
return {
|
||||
characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character],
|
||||
monsters: [
|
||||
{
|
||||
id: ASH_RAT_ID,
|
||||
key: 'ash-rat',
|
||||
experienceReward: 8,
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
lootTableId: ASH_RAT_TABLE,
|
||||
} as MonsterDefinition,
|
||||
{
|
||||
id: ROAD_BANDIT_ID,
|
||||
key: 'road-bandit',
|
||||
experienceReward: 16,
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
lootTableId: ROAD_BANDIT_TABLE,
|
||||
} as MonsterDefinition,
|
||||
],
|
||||
itemDefinitions: [
|
||||
{
|
||||
id: BANDIT_BLADE,
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
type: ItemType.WEAPON,
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
} as ItemDefinition,
|
||||
],
|
||||
characterItems: [],
|
||||
combatRewards: [],
|
||||
combatRewardItems: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDataSource(state: State): EntityManager {
|
||||
// `loadRewards`'s no-manager branch only calls `getRepository`, which
|
||||
// `fakeManager` already implements identically for `DataSource`; reusing
|
||||
// it (rather than duplicating the repository-resolution switch) keeps this
|
||||
// fake backed by the exact same `state` rows as the transactional path.
|
||||
return fakeManager(state);
|
||||
}
|
||||
|
||||
function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService {
|
||||
return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService;
|
||||
}
|
||||
|
||||
function fixedRandom(value: number): RandomSource {
|
||||
return { next: () => value };
|
||||
}
|
||||
|
||||
function service(
|
||||
state: State,
|
||||
loot: LootService = fakeLoot(),
|
||||
random: RandomSource = fixedRandom(0.5),
|
||||
): CombatRewardService {
|
||||
return new CombatRewardService({} as never, loot, random);
|
||||
}
|
||||
|
||||
describe('CombatRewardService', () => {
|
||||
describe('eligibility', () => {
|
||||
it('rejects an ACTIVE combat', async () => {
|
||||
const state = createState();
|
||||
|
||||
await expect(
|
||||
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })),
|
||||
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
|
||||
expect(state.combatRewards).toHaveLength(0);
|
||||
expect(state.characters[0].experience).toBe(12);
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects a LOST combat', async () => {
|
||||
const state = createState();
|
||||
|
||||
await expect(
|
||||
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })),
|
||||
).rejects.toBeInstanceOf(RewardDomainError);
|
||||
expect(state.combatRewards).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('grants rewards for a WON combat', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(state).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
expect(reward).toEqual({ experience: 8, silver: 6, items: [] });
|
||||
expect(state.combatRewards).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aschenratte', () => {
|
||||
it('grants 8 XP and a silver roll inside 4-7, persisted on the character', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
|
||||
fakeManager(state),
|
||||
combat(),
|
||||
);
|
||||
|
||||
expect(reward.experience).toBe(8);
|
||||
expect(reward.silver).toBe(4);
|
||||
expect(state.characters[0].experience).toBe(20);
|
||||
expect(state.characters[0].silver).toBe(7);
|
||||
});
|
||||
|
||||
it('rolls the top of the silver range from the top of the random range', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards(
|
||||
fakeManager(state),
|
||||
combat(),
|
||||
);
|
||||
|
||||
expect(reward.silver).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Straßenräuber', () => {
|
||||
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
|
||||
|
||||
it('grants 16 XP and a silver roll inside 9-15', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
|
||||
fakeManager(state),
|
||||
banditCombat,
|
||||
);
|
||||
|
||||
expect(reward.experience).toBe(16);
|
||||
expect(reward.silver).toBe(9);
|
||||
});
|
||||
|
||||
it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), banditCombat);
|
||||
|
||||
expect(state.characterItems).toEqual([
|
||||
expect.objectContaining({
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: BANDIT_BLADE,
|
||||
quantity: 1,
|
||||
}),
|
||||
]);
|
||||
expect(reward.items).toEqual([
|
||||
{
|
||||
characterItemId: state.characterItems[0].id,
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
]);
|
||||
expect(state.combatRewardItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports no items when the Räuberklinge does not drop', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(state, fakeLoot()).grantVictoryRewards(
|
||||
fakeManager(state),
|
||||
banditCombat,
|
||||
);
|
||||
|
||||
expect(reward.items).toEqual([]);
|
||||
expect(state.characterItems).toHaveLength(0);
|
||||
expect(state.combatRewardItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stacks a duplicate drop onto the existing CharacterItem without duplicate protection', async () => {
|
||||
const state = createState({
|
||||
characterItems: [
|
||||
{
|
||||
id: 'character-item-existing',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: BANDIT_BLADE,
|
||||
quantity: 1,
|
||||
} as CharacterItem,
|
||||
],
|
||||
});
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), banditCombat);
|
||||
|
||||
expect(state.characterItems).toHaveLength(1);
|
||||
expect(state.characterItems[0].quantity).toBe(2);
|
||||
// The reward reports what THIS combat granted, not the stack total.
|
||||
expect(reward.items[0].quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('grants once and returns the same persisted reward on a repeat call', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const manager = fakeManager(state);
|
||||
|
||||
const first = await subject.grantVictoryRewards(manager, combat());
|
||||
const second = await subject.grantVictoryRewards(manager, combat());
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(state.combatRewards).toHaveLength(1);
|
||||
expect(state.combatRewardItems).toHaveLength(1);
|
||||
expect(state.characterItems).toHaveLength(1);
|
||||
expect(state.characterItems[0].quantity).toBe(1);
|
||||
expect(state.characters[0].experience).toBe(20);
|
||||
expect(state.characters[0].silver).toBe(7);
|
||||
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadRewards', () => {
|
||||
it('returns null for a combat that was never rewarded', async () => {
|
||||
const state = createState();
|
||||
|
||||
await expect(
|
||||
service(state).loadRewards(COMBAT_ID, fakeManager(state)),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('replays the persisted reward without rerolling', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const manager = fakeManager(state);
|
||||
const granted = await subject.grantVictoryRewards(manager, combat());
|
||||
|
||||
const replayed = await subject.loadRewards(COMBAT_ID, manager);
|
||||
|
||||
expect(replayed).toEqual(granted);
|
||||
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads a persisted reward through the injected DataSource when no manager is passed', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const dataSource = fakeDataSource(state);
|
||||
const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0));
|
||||
const manager = fakeManager(state);
|
||||
|
||||
const granted = await subject.grantVictoryRewards(manager, combat());
|
||||
|
||||
// No manager argument: this is the non-transactional read the next
|
||||
// task uses to render a reward screen.
|
||||
const replayed = await subject.loadRewards(COMBAT_ID);
|
||||
|
||||
expect(replayed).toEqual(granted);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure handling', () => {
|
||||
it('throws instead of half-granting when a rolled item definition is missing', async () => {
|
||||
const state = createState();
|
||||
|
||||
await expect(
|
||||
service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards(
|
||||
fakeManager(state),
|
||||
combat(),
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' });
|
||||
expect(state.combatRewardItems).toHaveLength(0);
|
||||
// Every rolled item definition is resolved before any mutation, so a
|
||||
// missing one must leave no reward row and no character grant behind.
|
||||
expect(state.combatRewards).toHaveLength(0);
|
||||
expect(state.characters[0].experience).toBe(12);
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('item order', () => {
|
||||
it('orders items by itemDefinitionId in the immediate grant and the replayed reward, regardless of roll order', async () => {
|
||||
const state = createState({
|
||||
itemDefinitions: [
|
||||
{
|
||||
id: BANDIT_BLADE,
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
type: ItemType.WEAPON,
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
} as ItemDefinition,
|
||||
{
|
||||
id: BANDIT_HOOD,
|
||||
key: 'bandit-hood',
|
||||
name: 'Räuberkapuze',
|
||||
type: ItemType.ARMOR,
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/bandit-hood.png',
|
||||
} as ItemDefinition,
|
||||
],
|
||||
});
|
||||
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
|
||||
// Roll order is blade-then-hood, but BANDIT_HOOD's id sorts before
|
||||
// BANDIT_BLADE's, so this only passes if both response paths sort by
|
||||
// itemDefinitionId rather than returning rows in roll/insertion order.
|
||||
const loot = fakeLoot(
|
||||
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
|
||||
{ itemDefinitionId: BANDIT_HOOD, quantity: 1 },
|
||||
);
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const manager = fakeManager(state);
|
||||
|
||||
const granted = await subject.grantVictoryRewards(manager, banditCombat);
|
||||
const replayed = await subject.loadRewards(banditCombat.id, manager);
|
||||
|
||||
const expectedKeys = ['bandit-hood', 'bandit-blade'];
|
||||
expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys);
|
||||
expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys);
|
||||
expect(replayed).toEqual(granted);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
apps/api/src/rewards/combat-reward.service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
import { LootService } from '../loot/loot.service';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { rollInclusive } from '../shared/roll-range';
|
||||
import { CombatReward } from './entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
import { combatNotWon, rewardStateInvalid } from './rewards.errors';
|
||||
|
||||
export interface CombatRewardItemDto {
|
||||
characterItemId: string;
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
rarity: ItemRarity;
|
||||
iconPath: string;
|
||||
};
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CombatRewardDto {
|
||||
experience: number;
|
||||
silver: number;
|
||||
items: CombatRewardItemDto[];
|
||||
}
|
||||
|
||||
// Both DataSource and EntityManager expose this; naming it keeps the read path
|
||||
// usable inside and outside a transaction without a union type.
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
|
||||
@Injectable()
|
||||
export class CombatRewardService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly lootService: LootService,
|
||||
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Grants a won combat's rewards exactly once (spec §7, §21).
|
||||
*
|
||||
* Runs inside the caller's transaction — `CombatService.performAction`
|
||||
* already holds a pessimistic write lock on the combat row — so either
|
||||
* everything below commits or nothing does.
|
||||
*
|
||||
* Roll order is fixed: silver first, then the loot table in `position`
|
||||
* order. Tests depend on it.
|
||||
*/
|
||||
async grantVictoryRewards(
|
||||
manager: EntityManager,
|
||||
combat: Combat,
|
||||
): Promise<CombatRewardDto> {
|
||||
if (combat.status !== CombatStatus.WON) {
|
||||
throw combatNotWon();
|
||||
}
|
||||
|
||||
const rewards = manager.getRepository(CombatReward);
|
||||
const existing = await rewards.findOne({ where: { combatId: combat.id } });
|
||||
if (existing) {
|
||||
// Already rewarded: replay rather than roll again.
|
||||
return this.toDto(manager, existing);
|
||||
}
|
||||
|
||||
const monster = await manager
|
||||
.getRepository(MonsterDefinition)
|
||||
.findOneBy({ id: combat.monsterDefinitionId });
|
||||
if (!monster) {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
|
||||
const experience = monster.experienceReward;
|
||||
const silver = rollInclusive(
|
||||
this.randomSource,
|
||||
monster.silverMin,
|
||||
monster.silverMax,
|
||||
);
|
||||
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
|
||||
|
||||
// Resolve every rolled item definition up front, before any mutation, so
|
||||
// a missing definition throws `rewardStateInvalid()` before the
|
||||
// character's XP/silver are touched or a `CombatReward` row is created.
|
||||
// This keeps a failed grant from leaving partial writes behind.
|
||||
const definitions = manager.getRepository(ItemDefinition);
|
||||
const resolvedDefinitions = new Map<string, ItemDefinition>();
|
||||
for (const rolled of roll.items) {
|
||||
if (resolvedDefinitions.has(rolled.itemDefinitionId)) {
|
||||
continue;
|
||||
}
|
||||
const definition = await definitions.findOneBy({
|
||||
id: rolled.itemDefinitionId,
|
||||
});
|
||||
if (!definition) {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
resolvedDefinitions.set(rolled.itemDefinitionId, definition);
|
||||
}
|
||||
|
||||
const characters = manager.getRepository(Character);
|
||||
const character = await characters.findOne({
|
||||
where: { id: combat.characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
character.experience += experience;
|
||||
character.silver += silver;
|
||||
await characters.save(character);
|
||||
|
||||
const reward = await rewards.save(
|
||||
rewards.create({
|
||||
combatId: combat.id,
|
||||
characterId: combat.characterId,
|
||||
experienceGranted: experience,
|
||||
silverGranted: silver,
|
||||
}),
|
||||
);
|
||||
|
||||
const characterItems = manager.getRepository(CharacterItem);
|
||||
const rewardItems = manager.getRepository(CombatRewardItem);
|
||||
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = [];
|
||||
|
||||
for (const rolled of roll.items) {
|
||||
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
|
||||
|
||||
const existingStack = await characterItems.findOne({
|
||||
where: {
|
||||
characterId: combat.characterId,
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
},
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28).
|
||||
const characterItem = existingStack
|
||||
? Object.assign(existingStack, {
|
||||
quantity: existingStack.quantity + rolled.quantity,
|
||||
})
|
||||
: characterItems.create({
|
||||
characterId: combat.characterId,
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
quantity: rolled.quantity,
|
||||
});
|
||||
await characterItems.save(characterItem);
|
||||
|
||||
await rewardItems.save(
|
||||
rewardItems.create({
|
||||
combatRewardId: reward.id,
|
||||
characterItemId: characterItem.id,
|
||||
itemDefinitionId: definition.id,
|
||||
quantity: rolled.quantity,
|
||||
}),
|
||||
);
|
||||
|
||||
granted.push({
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
dto: this.toItemDto(characterItem.id, definition, rolled.quantity),
|
||||
});
|
||||
}
|
||||
|
||||
// The immediate response and a later `loadRewards` replay must agree on
|
||||
// item order; both sort on the same stable key (itemDefinitionId, which
|
||||
// `toDto`'s query also orders by) rather than roll order.
|
||||
granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId));
|
||||
const items = granted.map((entry) => entry.dto);
|
||||
|
||||
return { experience, silver, items };
|
||||
}
|
||||
|
||||
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */
|
||||
async loadRewards(
|
||||
combatId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<CombatRewardDto | null> {
|
||||
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||
const reward = await scope
|
||||
.getRepository(CombatReward)
|
||||
.findOne({ where: { combatId } });
|
||||
|
||||
return reward ? this.toDto(scope, reward) : null;
|
||||
}
|
||||
|
||||
private async toDto(
|
||||
scope: RepositoryScope,
|
||||
reward: CombatReward,
|
||||
): Promise<CombatRewardDto> {
|
||||
// Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards`
|
||||
// applies to its own response — the immediate grant and a later replay
|
||||
// must list items identically.
|
||||
const rewardItems = await scope
|
||||
.getRepository(CombatRewardItem)
|
||||
.find({
|
||||
where: { combatRewardId: reward.id },
|
||||
order: { itemDefinitionId: 'ASC' },
|
||||
});
|
||||
const definitions = scope.getRepository(ItemDefinition);
|
||||
|
||||
const items: CombatRewardItemDto[] = [];
|
||||
for (const rewardItem of rewardItems) {
|
||||
const definition = await definitions.findOneBy({
|
||||
id: rewardItem.itemDefinitionId,
|
||||
});
|
||||
if (!definition) {
|
||||
// combat_reward_items.item_definition_id is a RESTRICT FK.
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
items.push(
|
||||
this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
experience: reward.experienceGranted,
|
||||
silver: reward.silverGranted,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private toItemDto(
|
||||
characterItemId: string,
|
||||
definition: ItemDefinition,
|
||||
quantity: number,
|
||||
): CombatRewardItemDto {
|
||||
// Drop chance, roll results, and loot-table ids never leave the server
|
||||
// (spec §26).
|
||||
return {
|
||||
characterItemId,
|
||||
item: {
|
||||
key: definition.key,
|
||||
name: definition.name,
|
||||
rarity: definition.rarity,
|
||||
iconPath: definition.iconPath,
|
||||
},
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
}
|
||||
56
apps/api/src/rewards/entities/combat-reward-item.entity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { CombatReward } from './combat-reward.entity';
|
||||
|
||||
/**
|
||||
* What this specific combat dropped.
|
||||
*
|
||||
* Needed because `CharacterItem.quantity` is a running stack total: after a
|
||||
* duplicate drop it no longer says how much *this* victory granted, and a
|
||||
* refreshed reward screen must replay the original result (spec §25, §48).
|
||||
*/
|
||||
@Entity({ name: 'combat_reward_items' })
|
||||
@Index('IDX_combat_reward_items_reward', ['combatRewardId'])
|
||||
@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class CombatRewardItem {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'combat_reward_id', type: 'uuid' })
|
||||
combatRewardId!: string;
|
||||
|
||||
@Column({ name: 'character_item_id', type: 'uuid' })
|
||||
characterItemId!: string;
|
||||
|
||||
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||
itemDefinitionId!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'integer' })
|
||||
quantity!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => CombatReward, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'combat_reward_id' })
|
||||
combatReward!: CombatReward;
|
||||
|
||||
@ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'character_item_id' })
|
||||
characterItem!: CharacterItem;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'item_definition_id' })
|
||||
itemDefinition!: ItemDefinition;
|
||||
}
|
||||
48
apps/api/src/rewards/entities/combat-reward.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { Combat } from '../../combat/entities/combat.entity';
|
||||
|
||||
/**
|
||||
* Proof that one combat has already been rewarded (spec §8).
|
||||
*
|
||||
* The unique index on `combatId` is the database half of the idempotency
|
||||
* invariant; `CombatRewardService` is the service half.
|
||||
*/
|
||||
@Entity({ name: 'combat_rewards' })
|
||||
@Index('IDX_combat_rewards_combat', ['combatId'], { unique: true })
|
||||
@Index('IDX_combat_rewards_character', ['characterId'])
|
||||
export class CombatReward {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'combat_id', type: 'uuid' })
|
||||
combatId!: string;
|
||||
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({ name: 'experience_granted', type: 'integer' })
|
||||
experienceGranted!: number;
|
||||
|
||||
@Column({ name: 'silver_granted', type: 'integer' })
|
||||
silverGranted!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'combat_id' })
|
||||
combat!: Combat;
|
||||
|
||||
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
}
|
||||
29
apps/api/src/rewards/rewards.errors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type RewardErrorCode = 'COMBAT_NOT_WON' | 'REWARD_STATE_INVALID';
|
||||
|
||||
export class RewardDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: RewardErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function combatNotWon(): RewardDomainError {
|
||||
return new RewardDomainError(
|
||||
'COMBAT_NOT_WON',
|
||||
HttpStatus.CONFLICT,
|
||||
'Only a won combat can grant victory rewards.',
|
||||
);
|
||||
}
|
||||
|
||||
export function rewardStateInvalid(): RewardDomainError {
|
||||
return new RewardDomainError(
|
||||
'REWARD_STATE_INVALID',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
'The reward references unavailable data.',
|
||||
);
|
||||
}
|
||||
31
apps/api/src/rewards/rewards.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { LootModule } from '../loot/loot.module';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||
import { CombatRewardService } from './combat-reward.service';
|
||||
import { CombatReward } from './entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Character,
|
||||
CharacterItem,
|
||||
ItemDefinition,
|
||||
MonsterDefinition,
|
||||
CombatReward,
|
||||
CombatRewardItem,
|
||||
]),
|
||||
LootModule,
|
||||
],
|
||||
providers: [
|
||||
CombatRewardService,
|
||||
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
|
||||
],
|
||||
exports: [CombatRewardService],
|
||||
})
|
||||
export class RewardsModule {}
|
||||
28
apps/api/src/shared/roll-range.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { RandomSource } from './random-source';
|
||||
import { rollInclusive } from './roll-range';
|
||||
|
||||
function fixed(...values: number[]): RandomSource {
|
||||
let index = 0;
|
||||
return { next: () => values[index++] };
|
||||
}
|
||||
|
||||
describe('rollInclusive', () => {
|
||||
it('maps the bottom of the random range to min and the top to max', () => {
|
||||
expect(rollInclusive(fixed(0), 4, 7)).toBe(4);
|
||||
expect(rollInclusive(fixed(0.999), 4, 7)).toBe(7);
|
||||
});
|
||||
|
||||
it('spreads the random range evenly across every value in between', () => {
|
||||
expect(rollInclusive(fixed(0.25), 4, 7)).toBe(5);
|
||||
expect(rollInclusive(fixed(0.5), 4, 7)).toBe(6);
|
||||
expect(rollInclusive(fixed(0.5), 9, 15)).toBe(12);
|
||||
});
|
||||
|
||||
it('never exceeds max even if the source yields exactly 1', () => {
|
||||
expect(rollInclusive(fixed(1), 9, 15)).toBe(15);
|
||||
});
|
||||
|
||||
it('returns the single value when min equals max', () => {
|
||||
expect(rollInclusive(fixed(0.7), 1, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
19
apps/api/src/shared/roll-range.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RandomSource } from './random-source';
|
||||
|
||||
/**
|
||||
* Rolls an inclusive integer in [min, max] from one value of `random`.
|
||||
*
|
||||
* `RandomSource.next()` is documented as [0, 1), but the clamp keeps a
|
||||
* misbehaving or hand-stubbed source from ever exceeding `max`.
|
||||
*/
|
||||
export function rollInclusive(
|
||||
random: RandomSource,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
|
||||
return Math.min(max, min + Math.floor(random.next() * (max - min + 1)));
|
||||
}
|
||||
@@ -179,6 +179,7 @@ function createState(): FakeState {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumError": "12kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
BIN
apps/web/public/assets/cursors/bg/click.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/assets/cursors/bg/cursor.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/web/public/assets/cursors/bg/disabled.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/web/public/assets/cursors/bg/hover.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/web/public/assets/cursors/click.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/web/public/assets/cursors/cursor.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/web/public/assets/cursors/disabled.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/web/public/assets/cursors/hover.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/web/public/assets/hud-elements/character-statusbar.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/web/public/assets/hud-elements/drop-card.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/assets/hud-elements/enemy-card.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/assets/hud-elements/fight-action-background.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/assets/hud-elements/highlight-frame.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/assets/hud-elements/list-bg.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/assets/hud-elements/panel-bg.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/assets/hud-elements/sidepanel-left.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/assets/hud-elements/statusbar-bg.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
apps/web/public/assets/hud-elements/statusbar.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/web/public/assets/hud-elements/x.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/web/public/images/combat/icons/ash-rat-128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/images/combat/icons/charred-looter-128.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/web/public/images/combat/icons/road-bandit-128.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/images/combat/icons/wild-road-dog-128.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/images/combat/sprites/ash-rat-760.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
apps/web/public/images/combat/sprites/charred-looter-620.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
apps/web/public/images/combat/sprites/road-bandit-620.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 243 KiB |
BIN
apps/web/public/images/combat/sprites/warrior-hit-sheet-384.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
apps/web/public/images/combat/sprites/wild-road-dog-760.png
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
apps/web/public/images/hud/runtime/AttackIcon-96.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
apps/web/public/images/hud/runtime/defeated-mark-256.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/web/public/images/hud/runtime/enemy-card-560.webp
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
apps/web/public/images/hud/runtime/fight-action-frame-360.png
Normal file
|
After Width: | Height: | Size: 61 KiB |