Compare commits
141 Commits
5b662aad4c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221819880c | ||
|
|
ab6227881e | ||
|
|
848f9d9195 | ||
|
|
c27b6c5026 | ||
|
|
caf63b34a4 | ||
|
|
cfe1866956 | ||
|
|
52178ba79d | ||
|
|
dd204db4b3 | ||
|
|
b070bf2b0d | ||
|
|
ac1329be46 | ||
|
|
432483e958 | ||
|
|
909f793eb6 | ||
|
|
02037b2917 | ||
|
|
47494abf50 | ||
|
|
71f58bc1b6 | ||
|
|
ea26e4b844 | ||
|
|
19e08f0b16 | ||
|
|
b55518b451 | ||
|
|
2579b23a5c | ||
|
|
a75a670509 | ||
|
|
dd90e05446 | ||
|
|
987242541d | ||
|
|
a376fb7128 | ||
|
|
65dfb466cd | ||
|
|
62d6677298 | ||
|
|
1aac3416fa | ||
|
|
e2f29d5eb6 | ||
|
|
0126d1dea1 | ||
|
|
b8d00278b8 | ||
|
|
c7b9601eb4 | ||
|
|
43d2085d2c | ||
|
|
6eb0f21360 | ||
|
|
b3dac8f61c | ||
|
|
fc1873e41f | ||
|
|
922c12f20f | ||
|
|
6f1afaf5a3 | ||
|
|
eed247d318 | ||
|
|
96d04d8ba3 | ||
|
|
67df86a818 | ||
|
|
9d396b7d96 | ||
|
|
49a0008a2b | ||
|
|
0ce3b420e6 | ||
|
|
7f031ac1ce | ||
|
|
b1968da754 | ||
|
|
724443de1e | ||
|
|
fdd8ae4e41 | ||
|
|
9cb0158d80 | ||
|
|
1015505f38 | ||
|
|
973d4e3ab4 | ||
|
|
10dd838465 | ||
|
|
c994a4c46f | ||
|
|
90094ba1ae | ||
|
|
6178b88573 | ||
|
|
2526ac230d | ||
|
|
2859a00597 | ||
|
|
820c69704f | ||
|
|
d1c7ffea86 | ||
|
|
9df740e7d4 | ||
|
|
3ee107694c | ||
|
|
f45f4b03cb | ||
|
|
e5746dec5c | ||
|
|
67c29e783f | ||
|
|
8a5f57fa1c | ||
|
|
8debb6f550 | ||
|
|
59e88bcf9c | ||
|
|
2cbb7cb235 | ||
|
|
c158260623 | ||
|
|
60e1734f59 | ||
|
|
ec91f3ac7b | ||
|
|
d31d064d36 | ||
|
|
9b839623ce | ||
|
|
6f0020137b | ||
|
|
d174d46fbd | ||
|
|
c7e7e97252 | ||
|
|
a87a13fad2 | ||
|
|
3c59603efb | ||
|
|
2664495ef4 | ||
|
|
624c47e5a6 | ||
|
|
c85a70484f | ||
|
|
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 | ||
|
|
f24a5fea9c | ||
|
|
deab7a31f1 | ||
|
|
137a18f4e7 | ||
|
|
833fa52d8d | ||
|
|
26909d7e2a | ||
|
|
4e684dc45e | ||
|
|
784bd3ce3a | ||
|
|
15aa83d221 | ||
|
|
59269a7608 | ||
|
|
49eb110b25 | ||
|
|
ed03726931 | ||
|
|
bb307da0ff | ||
|
|
568478dcd2 | ||
|
|
9423c28bd8 | ||
|
|
e04827cd5a | ||
|
|
0c2f079a6e |
17
.claude/launch.json
Normal file
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ coverage/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CharactersModule } from './characters/characters.module';
|
import { CharactersModule } from './characters/characters.module';
|
||||||
|
import { CombatModule } from './combat/combat.module';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { HuntingModule } from './hunting/hunting.module';
|
||||||
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { TravelModule } from './travel/travel.module';
|
import { TravelModule } from './travel/travel.module';
|
||||||
import { WorldModule } from './world/world.module';
|
import { WorldModule } from './world/world.module';
|
||||||
|
|
||||||
@@ -12,6 +16,10 @@ import { WorldModule } from './world/world.module';
|
|||||||
CharactersModule,
|
CharactersModule,
|
||||||
TravelModule,
|
TravelModule,
|
||||||
WorldModule,
|
WorldModule,
|
||||||
|
HuntingModule,
|
||||||
|
CombatModule,
|
||||||
|
EquipmentModule,
|
||||||
|
InventoryModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
155
apps/api/src/characters/character-stats.service.spec.ts
Normal file
155
apps/api/src/characters/character-stats.service.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// apps/api/src/characters/character-stats.service.spec.ts
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
|
||||||
|
type EquippedFixture = {
|
||||||
|
slot: EquipmentSlot;
|
||||||
|
item: Partial<ItemDefinition>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
|
||||||
|
const rows = equipped.map((entry) => ({
|
||||||
|
slot: entry.slot,
|
||||||
|
characterItem: {
|
||||||
|
itemDefinition: {
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
...entry.item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
getRepository: () => ({ find: async () => rows }) as never,
|
||||||
|
} as unknown as Pick<DataSource, 'getRepository'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(overrides: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'character-1',
|
||||||
|
baseHp: 100,
|
||||||
|
baseAttack: 6,
|
||||||
|
currentHp: 90,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterStatsService', () => {
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-21T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const service = new CharacterStatsService({} as DataSource, characterVitals);
|
||||||
|
|
||||||
|
it('derives stats from the starting weapon alone', async () => {
|
||||||
|
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
expect(stats.attack).toBe(6);
|
||||||
|
expect(stats.weaponDamage).toBe(8);
|
||||||
|
expect(stats.maxHp).toBe(100);
|
||||||
|
expect(stats.armor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
|
||||||
|
const scope = fakeScope([
|
||||||
|
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
expect(stats.attack).toBe(7);
|
||||||
|
expect(stats.weaponDamage).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums bonusArmor across multiple equipped armor pieces', async () => {
|
||||||
|
const scope = fakeScope([
|
||||||
|
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
|
||||||
|
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
expect(stats.armor).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums bonusHp across equipped items on top of base HP', async () => {
|
||||||
|
const scope = fakeScope([
|
||||||
|
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
|
||||||
|
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
expect(stats.maxHp).toBe(115);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
|
||||||
|
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
expect(stats.weaponDamage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
|
||||||
|
const scope = fakeScope([
|
||||||
|
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
|
||||||
|
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(character(), scope);
|
||||||
|
|
||||||
|
// maxHp=105, attack=7, weaponDamage=11, armor=3
|
||||||
|
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the raw current HP unchanged while regeneration is paused', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(
|
||||||
|
character({ currentHp: 42, hpRegenSince: null }),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stats.currentHp).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
|
const regenerating = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(regenerating.currentHp).toBe(70);
|
||||||
|
|
||||||
|
const clamped = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(clamped.currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
const anchor = new Date('2026-08-21T11:59:30.000Z');
|
||||||
|
|
||||||
|
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
|
||||||
|
|
||||||
|
expect(stats.hpRegenPerSecond).toBe(1);
|
||||||
|
expect(stats.hpRegenSince).toEqual(anchor);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/api/src/characters/character-stats.service.ts
Normal file
73
apps/api/src/characters/character-stats.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
export interface EffectiveCharacterStats {
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
attack: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
armor: number;
|
||||||
|
combatPower: number;
|
||||||
|
hpRegenPerSecond: number;
|
||||||
|
hpRegenSince: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single authoritative source of effective character stats (spec §18).
|
||||||
|
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CharacterStatsService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async calculate(
|
||||||
|
character: Character,
|
||||||
|
scope?: RepositoryScope,
|
||||||
|
): Promise<EffectiveCharacterStats> {
|
||||||
|
const db = scope ?? this.dataSource;
|
||||||
|
const equipped = await db.getRepository(CharacterEquipment).find({
|
||||||
|
where: { characterId: character.id },
|
||||||
|
relations: { characterItem: { itemDefinition: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
let weaponDamage = 0;
|
||||||
|
let bonusHp = 0;
|
||||||
|
let bonusAttack = 0;
|
||||||
|
let bonusArmor = 0;
|
||||||
|
|
||||||
|
for (const slot of equipped) {
|
||||||
|
const definition = slot.characterItem.itemDefinition;
|
||||||
|
if (slot.slot === EquipmentSlot.WEAPON) {
|
||||||
|
weaponDamage = definition.weaponDamage;
|
||||||
|
}
|
||||||
|
bonusHp += definition.bonusHp;
|
||||||
|
bonusAttack += definition.bonusAttack;
|
||||||
|
bonusArmor += definition.bonusArmor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxHp = character.baseHp + bonusHp;
|
||||||
|
const attack = character.baseAttack + bonusAttack;
|
||||||
|
const armor = bonusArmor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxHp,
|
||||||
|
currentHp: this.characterVitals.effectiveHp(character, maxHp),
|
||||||
|
attack,
|
||||||
|
weaponDamage,
|
||||||
|
armor,
|
||||||
|
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||||
|
hpRegenPerSecond: HP_REGEN_PER_SECOND,
|
||||||
|
hpRegenSince: character.hpRegenSince,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const HP_REGEN_PER_SECOND = 1;
|
||||||
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Clock } from '../shared/clock';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
function fakeClock(initialIso: string): {
|
||||||
|
clock: Clock;
|
||||||
|
advanceSeconds: (seconds: number) => void;
|
||||||
|
} {
|
||||||
|
let current = Date.parse(initialIso);
|
||||||
|
return {
|
||||||
|
clock: { now: () => new Date(current) },
|
||||||
|
advanceSeconds: (seconds: number) => {
|
||||||
|
current += seconds * 1000;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(overrides: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'character-1',
|
||||||
|
currentHp: 50,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterVitalsService', () => {
|
||||||
|
describe('effectiveHp', () => {
|
||||||
|
it('returns the raw current HP when regeneration is paused', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
|
||||||
|
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100);
|
||||||
|
|
||||||
|
expect(hp).toBe(37);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds one HP per elapsed second since the anchor', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(25);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors partial seconds instead of rounding up', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(1.9);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(41);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps regeneration at maxHp', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 90, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(50);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets HP fall if the clock moves backwards', () => {
|
||||||
|
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pause', () => {
|
||||||
|
it('freezes current HP at the given value and clears the anchor', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const target = character({
|
||||||
|
currentHp: 100,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
service.pause(target, 62);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(62);
|
||||||
|
expect(target.hpRegenSince).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resume', () => {
|
||||||
|
it('sets current HP and anchors regeneration at now', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const target = character({ currentHp: 0, hpRegenSince: null });
|
||||||
|
|
||||||
|
service.resume(target, 15);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(15);
|
||||||
|
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('settle', () => {
|
||||||
|
it('re-anchors at the current effective value without changing it', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
advanceSeconds(10);
|
||||||
|
|
||||||
|
service.settle(target, 100);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(50);
|
||||||
|
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 100, hpRegenSince: anchor });
|
||||||
|
advanceSeconds(600);
|
||||||
|
|
||||||
|
service.settle(target, 100);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/api/src/characters/character-vitals.service.ts
Normal file
46
apps/api/src/characters/character-vitals.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { CLOCK } from '../shared/clock';
|
||||||
|
import type { Clock } from '../shared/clock';
|
||||||
|
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place that turns (current_hp, hp_regen_since) into an effective
|
||||||
|
* HP value, or moves that pair. `current_hp` is exact only while the anchor
|
||||||
|
* is null; everything else must go through here (persistent-hp-and-
|
||||||
|
* regeneration design, R3).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CharacterVitalsService {
|
||||||
|
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
|
||||||
|
|
||||||
|
effectiveHp(
|
||||||
|
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
|
||||||
|
maxHp: number,
|
||||||
|
): number {
|
||||||
|
if (character.hpRegenSince === null) {
|
||||||
|
return Math.min(maxHp, character.currentHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
|
||||||
|
);
|
||||||
|
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
|
||||||
|
return Math.min(maxHp, character.currentHp + regenerated);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(character: Character, value: number): void {
|
||||||
|
character.currentHp = value;
|
||||||
|
character.hpRegenSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(character: Character, value: number): void {
|
||||||
|
character.currentHp = value;
|
||||||
|
character.hpRegenSince = this.clock.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(character: Character, maxHp: number): void {
|
||||||
|
this.resume(character, this.effectiveHp(character, maxHp));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CLOCK, systemClock } from '../shared/clock';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
import { CharactersController } from './characters.controller';
|
import { CharactersController } from './characters.controller';
|
||||||
import { CharactersService } from './characters.service';
|
import { CharactersService } from './characters.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
@@ -7,6 +10,12 @@ import { Character } from './entities/character.entity';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Character])],
|
imports: [TypeOrmModule.forFeature([Character])],
|
||||||
controllers: [CharactersController],
|
controllers: [CharactersController],
|
||||||
providers: [CharactersService],
|
providers: [
|
||||||
|
CharactersService,
|
||||||
|
CharacterStatsService,
|
||||||
|
CharacterVitalsService,
|
||||||
|
{ provide: CLOCK, useValue: systemClock },
|
||||||
|
],
|
||||||
|
exports: [CharacterStatsService, CharacterVitalsService],
|
||||||
})
|
})
|
||||||
export class CharactersModule {}
|
export class CharactersModule {}
|
||||||
|
|||||||
@@ -2,54 +2,101 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
import { CharactersService } from './characters.service';
|
import { CharactersService } from './characters.service';
|
||||||
|
|
||||||
|
function fakeCharacterStats(
|
||||||
|
overrides: Partial<{ maxHp: number; attack: number }> = {},
|
||||||
|
): CharacterStatsService {
|
||||||
|
return {
|
||||||
|
calculate: jest.fn().mockResolvedValue({
|
||||||
|
maxHp: overrides.maxHp ?? 100,
|
||||||
|
currentHp: 100,
|
||||||
|
attack: overrides.attack ?? 6,
|
||||||
|
weaponDamage: 8,
|
||||||
|
armor: 0,
|
||||||
|
combatPower: 0,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
} as unknown as CharacterStatsService;
|
||||||
|
}
|
||||||
|
|
||||||
describe('CharactersService', () => {
|
describe('CharactersService', () => {
|
||||||
it('returns the demo character with its current location summary', async () => {
|
it('returns the demo character with effective attack/HP and its location summary', async () => {
|
||||||
const repository = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
|
||||||
|
const service = new CharactersService(repository, characterStats);
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).resolves.toEqual({
|
await expect(service.getDemoCharacter()).resolves.toEqual({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 115,
|
||||||
attack: 6,
|
attack: 7,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: '2026-08-18T09:00:00.000Z',
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(repository.findOne).toHaveBeenCalledWith({
|
expect(characterStats.calculate).toHaveBeenCalledWith(
|
||||||
where: { id: DEMO_CHARACTER_ID },
|
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||||
relations: { currentLocation: true },
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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, fakeCharacterStats());
|
||||||
|
|
||||||
|
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||||
|
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a missing demo seed as not found', async () => {
|
it('reports a missing demo seed as not found', async () => {
|
||||||
const repository = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const service = new CharactersService(repository, fakeCharacterStats());
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,6 +10,7 @@ export class CharactersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Character)
|
@InjectRepository(Character)
|
||||||
private readonly characters: Repository<Character>,
|
private readonly characters: Repository<Character>,
|
||||||
|
private readonly characterStats: CharacterStatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getDemoCharacter() {
|
async getDemoCharacter() {
|
||||||
@@ -21,14 +23,19 @@ export class CharactersService {
|
|||||||
throw new NotFoundException('Demo character has not been seeded');
|
throw new NotFoundException('Demo character has not been seeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = await this.characterStats.calculate(character);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
level: character.level,
|
level: character.level,
|
||||||
experience: character.experience,
|
experience: character.experience,
|
||||||
currentHp: character.currentHp,
|
silver: character.silver,
|
||||||
maxHp: character.baseHp,
|
currentHp: stats.currentHp,
|
||||||
attack: character.baseAttack,
|
maxHp: stats.maxHp,
|
||||||
|
attack: stats.attack,
|
||||||
|
hpRegenPerSecond: stats.hpRegenPerSecond,
|
||||||
|
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null,
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: character.currentLocation.id,
|
id: character.currentLocation.id,
|
||||||
key: character.currentLocation.key,
|
key: character.currentLocation.key,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class Character {
|
|||||||
@Column({ name: 'experience', type: 'integer' })
|
@Column({ name: 'experience', type: 'integer' })
|
||||||
experience!: number;
|
experience!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'silver', type: 'integer' })
|
||||||
|
silver!: number;
|
||||||
|
|
||||||
@Column({ name: 'base_hp', type: 'integer' })
|
@Column({ name: 'base_hp', type: 'integer' })
|
||||||
baseHp!: number;
|
baseHp!: number;
|
||||||
|
|
||||||
@@ -32,6 +35,12 @@ export class Character {
|
|||||||
@Column({ name: 'current_hp', type: 'integer' })
|
@Column({ name: 'current_hp', type: 'integer' })
|
||||||
currentHp!: number;
|
currentHp!: number;
|
||||||
|
|
||||||
|
// `current_hp` is only exact while this is null (regeneration paused, e.g.
|
||||||
|
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
|
||||||
|
// through CharacterVitalsService.effectiveHp(), never directly.
|
||||||
|
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
|
||||||
|
hpRegenSince!: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'current_location_id', type: 'uuid' })
|
@Column({ name: 'current_location_id', type: 'uuid' })
|
||||||
currentLocationId!: string;
|
currentLocationId!: string;
|
||||||
|
|
||||||
|
|||||||
7
apps/api/src/combat/combat-action.enum.ts
Normal file
7
apps/api/src/combat/combat-action.enum.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export enum CombatAction {
|
||||||
|
ATTACK = 'ATTACK',
|
||||||
|
HEAVY_STRIKE = 'HEAVY_STRIKE',
|
||||||
|
SHIELD_BASH = 'SHIELD_BASH',
|
||||||
|
DEFEND = 'DEFEND',
|
||||||
|
POTION = 'POTION',
|
||||||
|
}
|
||||||
26
apps/api/src/combat/combat-damage.spec.ts
Normal file
26
apps/api/src/combat/combat-damage.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a damage multiplier before the minimum-1 floor', () => {
|
||||||
|
// raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32
|
||||||
|
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still floors at 1 damage even with a small multiplier', () => {
|
||||||
|
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/api/src/combat/combat-damage.ts
Normal file
17
apps/api/src/combat/combat-damage.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface DamageAttacker {
|
||||||
|
attack: number;
|
||||||
|
weaponDamage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARMOR_MITIGATION_CONSTANT = 60;
|
||||||
|
|
||||||
|
export function calculateDamage(
|
||||||
|
attacker: DamageAttacker,
|
||||||
|
targetArmor: number,
|
||||||
|
multiplier = 1,
|
||||||
|
): 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 * multiplier));
|
||||||
|
}
|
||||||
290
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
290
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
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: 'FLEE' as CombatAction }),
|
||||||
|
).toThrow(UnsupportedCombatActionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 22,
|
||||||
|
});
|
||||||
|
expect(result.state.monster.currentHp).toBe(45 - 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 45,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 },
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT },
|
||||||
|
]);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.state.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND });
|
||||||
|
|
||||||
|
expect(result.state.monster.currentHp).toBe(45);
|
||||||
|
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
|
||||||
|
]);
|
||||||
|
expect(result.state.player.currentHp).toBe(98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 60,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 35,
|
||||||
|
});
|
||||||
|
expect(result.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 5,
|
||||||
|
});
|
||||||
|
// 60 + 35 healed - 5 monster hit = 90
|
||||||
|
expect(result.state.player.currentHp).toBe(90);
|
||||||
|
expect(result.state.player.stats.potionsRemaining).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps POTION healing at the maximum HP', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 90,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35, but only 10 HP is missing
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
|
||||||
|
const round3State = baseState({ round: 3 });
|
||||||
|
|
||||||
|
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(telegraphResult.state.player.currentHp).toBe(100);
|
||||||
|
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
||||||
|
expect(telegraphResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.TELEGRAPH,
|
||||||
|
});
|
||||||
|
expect(telegraphResult.state.round).toBe(4);
|
||||||
|
|
||||||
|
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
|
||||||
|
expect(resolveResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 7,
|
||||||
|
});
|
||||||
|
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 10,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(result.state.status).toBe(CombatStatus.WON);
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
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('is deterministic for HEAVY_STRIKE as well', () => {
|
||||||
|
const state = baseState();
|
||||||
|
|
||||||
|
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 45,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
|
||||||
|
|
||||||
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4 },
|
||||||
|
]);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.state.player.currentHp).toBe(100 - 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
187
apps/api/src/combat/combat-engine.service.ts
Normal file
187
apps/api/src/combat/combat-engine.service.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CombatAction } from './combat-action.enum';
|
||||||
|
import { calculateDamage } from './combat-damage';
|
||||||
|
import { Combatant } from './combatant.enum';
|
||||||
|
import {
|
||||||
|
CombatActionInput,
|
||||||
|
CombatEngineCombatant,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The monster telegraphs a Heavy Attack instead of striking every third
|
||||||
|
// round it acts, then resolves it the round after unless SHIELD_BASH
|
||||||
|
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
|
||||||
|
// (Playable Slice 0.6 spec §10/§11).
|
||||||
|
const TELEGRAPH_ROUND_INTERVAL = 3;
|
||||||
|
const HEAVY_ATTACK_MULTIPLIER = 1.6;
|
||||||
|
const SHIELD_BASH_MULTIPLIER = 0.7;
|
||||||
|
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
|
||||||
|
const POTION_HEAL_FRACTION = 0.35;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CombatEngineService {
|
||||||
|
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
||||||
|
switch (input.action) {
|
||||||
|
case CombatAction.ATTACK:
|
||||||
|
return this.resolvePlayerStrike(state, 1);
|
||||||
|
case CombatAction.HEAVY_STRIKE:
|
||||||
|
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
|
||||||
|
case CombatAction.SHIELD_BASH:
|
||||||
|
return this.resolveShieldBash(state);
|
||||||
|
case CombatAction.DEFEND:
|
||||||
|
return this.resolveDefend(state);
|
||||||
|
case CombatAction.POTION:
|
||||||
|
return this.resolvePotion(state);
|
||||||
|
default:
|
||||||
|
throw new UnsupportedCombatActionError(input.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
const events: CombatEngineEvent[] = [];
|
||||||
|
|
||||||
|
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
|
||||||
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
const events: CombatEngineEvent[] = [];
|
||||||
|
|
||||||
|
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
|
||||||
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
|
||||||
|
let interrupted = false;
|
||||||
|
if (monster.stats.pendingAction) {
|
||||||
|
monster.stats.pendingAction = undefined;
|
||||||
|
interrupted = true;
|
||||||
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, false, interrupted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDefend(state: CombatEngineState): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
const events: CombatEngineEvent[] = [
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePotion(state: CombatEngineState): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
|
||||||
|
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
|
||||||
|
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
|
||||||
|
player.currentHp += healed;
|
||||||
|
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
|
||||||
|
|
||||||
|
const events: CombatEngineEvent[] = [
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRound(
|
||||||
|
state: CombatEngineState,
|
||||||
|
player: CombatEngineCombatant,
|
||||||
|
monster: CombatEngineCombatant,
|
||||||
|
events: CombatEngineEvent[],
|
||||||
|
defended: boolean,
|
||||||
|
interrupted = false,
|
||||||
|
): CombatEngineResult {
|
||||||
|
if (monster.currentHp <= 0) {
|
||||||
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
|
||||||
|
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } };
|
||||||
|
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interrupted) {
|
||||||
|
this.resolveMonsterTurn(state.round, monster, player, defended, events);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMonsterTurn(
|
||||||
|
round: number,
|
||||||
|
monster: CombatEngineCombatant,
|
||||||
|
player: CombatEngineCombatant,
|
||||||
|
defended: boolean,
|
||||||
|
events: CombatEngineEvent[],
|
||||||
|
): void {
|
||||||
|
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
|
||||||
|
|
||||||
|
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
|
||||||
|
monster.stats.pendingAction = undefined;
|
||||||
|
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
|
||||||
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
|
||||||
|
monster.stats.pendingAction = 'HEAVY_ATTACK';
|
||||||
|
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
|
||||||
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
|
||||||
|
return { ...combatant, stats: { ...combatant.stats } };
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/combat/combat-engine.types.ts
Normal file
50
apps/api/src/combat/combat-engine.types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Combatant } from './combatant.enum';
|
||||||
|
import { CombatAction } from './combat-action.enum';
|
||||||
|
import { CombatEventType } from './combat-event-type.enum';
|
||||||
|
import { CombatStatus } from './combat-status.enum';
|
||||||
|
|
||||||
|
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
|
||||||
|
// immediately and is never held as a pending intent (Playable Slice 0.6
|
||||||
|
// spec §5). Add members here as future slices add more prepared actions.
|
||||||
|
export type CombatIntent = 'HEAVY_ATTACK';
|
||||||
|
|
||||||
|
export interface CombatEngineCombatantStats {
|
||||||
|
attack: number;
|
||||||
|
weaponDamage?: number;
|
||||||
|
armor: number;
|
||||||
|
// Player-only: seeded at combat start, decremented by POTION. Optional
|
||||||
|
// because the monster's stats never carry it.
|
||||||
|
potionsRemaining?: number;
|
||||||
|
// Monster-only: set when it telegraphs, cleared when the action resolves
|
||||||
|
// or is interrupted. Optional because the player's stats never carry it.
|
||||||
|
pendingAction?: CombatIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
396
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
396
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
|
import { EquipmentService } from '../equipment/equipment.service';
|
||||||
|
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 { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { ItemType } from '../items/item-type.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 { CombatService } from './combat.service';
|
||||||
|
import { CombatEvent } from './entities/combat-event.entity';
|
||||||
|
import { Combat } from './entities/combat.entity';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
|
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
|
||||||
|
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
|
||||||
|
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
|
||||||
|
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
|
||||||
|
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
|
||||||
|
|
||||||
|
interface FakeState {
|
||||||
|
characters: Character[];
|
||||||
|
hunts: Hunt[];
|
||||||
|
huntEncounters: HuntEncounter[];
|
||||||
|
monsters: MonsterDefinition[];
|
||||||
|
combats: Combat[];
|
||||||
|
combatEvents: CombatEvent[];
|
||||||
|
itemDefinitions: ItemDefinition[];
|
||||||
|
characterItems: CharacterItem[];
|
||||||
|
characterEquipment: CharacterEquipment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRepository<T extends { id: string }> {
|
||||||
|
constructor(
|
||||||
|
private readonly state: FakeState,
|
||||||
|
private readonly target: EntityTarget<T>,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findOne(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
relations?: Record<string, unknown>;
|
||||||
|
lock?: { mode: string };
|
||||||
|
}): Promise<T | null> {
|
||||||
|
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
|
||||||
|
return Promise.resolve(row ? this.withRelations(row, options.relations) : 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>;
|
||||||
|
relations?: Record<string, unknown>;
|
||||||
|
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||||
|
}): Promise<T[]> {
|
||||||
|
const matched = this.rows().filter((row) => this.matches(row, options.where));
|
||||||
|
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 withRelations(row: T, relations?: Record<string, unknown>): T {
|
||||||
|
if (!relations) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const copy = { ...row } as T & Record<string, unknown>;
|
||||||
|
if (this.target === CharacterItem && relations['itemDefinition']) {
|
||||||
|
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
|
||||||
|
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
|
||||||
|
}
|
||||||
|
if (this.target === CharacterEquipment && relations['characterItem']) {
|
||||||
|
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
|
||||||
|
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
|
||||||
|
copy['characterItem'] = characterItem
|
||||||
|
? {
|
||||||
|
...characterItem,
|
||||||
|
itemDefinition: this.state.itemDefinitions.find(
|
||||||
|
(d) => d.id === characterItem.itemDefinitionId,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
return copy as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
|
||||||
|
if (this.target === CharacterItem) return this.state.characterItems as T[];
|
||||||
|
if (this.target === CharacterEquipment) return this.state.characterEquipment 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 FakeDataSource {
|
||||||
|
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, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
|
||||||
|
return work({
|
||||||
|
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
|
||||||
|
this.getRepository(target),
|
||||||
|
} as unknown as EntityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
hpRegenSince: null,
|
||||||
|
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: 'road-bandit',
|
||||||
|
name: 'Straßenräuber',
|
||||||
|
level: 2,
|
||||||
|
maxHp: 75,
|
||||||
|
attack: 9,
|
||||||
|
armor: 5,
|
||||||
|
experienceReward: 16,
|
||||||
|
silverMin: 9,
|
||||||
|
silverMax: 15,
|
||||||
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as MonsterDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
||||||
|
return {
|
||||||
|
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 itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
|
||||||
|
return {
|
||||||
|
id: WORN_SWORD_DEFINITION_ID,
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: '',
|
||||||
|
type: ItemType.WEAPON,
|
||||||
|
equipmentSlot: EquipmentSlot.WEAPON,
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
tier: 1,
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
sellPrice: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as ItemDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTravelService(): TravelService {
|
||||||
|
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRewardService(): CombatRewardService {
|
||||||
|
return {
|
||||||
|
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
|
||||||
|
loadRewards: jest.fn().mockResolvedValue(null),
|
||||||
|
} as unknown as CombatRewardService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const state: FakeState = {
|
||||||
|
characters: [character()],
|
||||||
|
hunts: [hunt()],
|
||||||
|
huntEncounters: [],
|
||||||
|
monsters: [monster()],
|
||||||
|
combats: [],
|
||||||
|
combatEvents: [],
|
||||||
|
itemDefinitions: [
|
||||||
|
itemDefinition(),
|
||||||
|
itemDefinition({
|
||||||
|
id: BANDIT_BLADE_DEFINITION_ID,
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
|
||||||
|
quantity: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as CharacterItem,
|
||||||
|
{
|
||||||
|
id: BANDIT_BLADE_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
|
||||||
|
quantity: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
characterEquipment: [
|
||||||
|
{
|
||||||
|
id: 'equip-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
slot: EquipmentSlot.WEAPON,
|
||||||
|
characterItemId: WORN_SWORD_ITEM_ID,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as CharacterEquipment,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const dataSource = new FakeDataSource(state);
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const characterStats = new CharacterStatsService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const equipmentService = new EquipmentService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const combatService = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
fakeRewardService(),
|
||||||
|
);
|
||||||
|
return { state, equipmentService, combatService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => {
|
||||||
|
it('deals more damage against the same monster after the upgrade than before it', async () => {
|
||||||
|
const { state, combatService, equipmentService } = createHarness();
|
||||||
|
|
||||||
|
state.huntEncounters.push(encounter('encounter-1'));
|
||||||
|
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
|
||||||
|
let beforeDamage = 0;
|
||||||
|
let beforeResult = before;
|
||||||
|
for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) {
|
||||||
|
beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK);
|
||||||
|
if (round === 0) {
|
||||||
|
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Equipment cannot change during an active combat (spec §46), so this
|
||||||
|
// first fight must be resolved to completion before equipping.
|
||||||
|
expect(beforeResult.status).not.toBe('ACTIVE');
|
||||||
|
|
||||||
|
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||||
|
|
||||||
|
state.huntEncounters.push(encounter('encounter-2'));
|
||||||
|
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
|
||||||
|
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK);
|
||||||
|
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
|
||||||
|
|
||||||
|
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
|
||||||
|
expect(beforeDamage).toBe(13);
|
||||||
|
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
|
||||||
|
expect(afterDamage).toBe(17);
|
||||||
|
expect(afterDamage).toBeGreaterThan(beforeDamage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => {
|
||||||
|
const { state, combatService, equipmentService } = createHarness();
|
||||||
|
state.huntEncounters.push(encounter('encounter-1'));
|
||||||
|
|
||||||
|
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
|
||||||
|
|
||||||
|
// Equipment cannot change while this combat is ACTIVE (spec §46).
|
||||||
|
await expect(
|
||||||
|
equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID),
|
||||||
|
).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' });
|
||||||
|
|
||||||
|
// Resolve the fight, then equip — the already-finished combat's snapshot
|
||||||
|
// (status/round) must stay exactly what it was when the fight ended.
|
||||||
|
let result = combat;
|
||||||
|
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
|
||||||
|
result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK);
|
||||||
|
}
|
||||||
|
expect(result.status).not.toBe('ACTIVE');
|
||||||
|
|
||||||
|
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||||
|
|
||||||
|
// The finished combat's playerState snapshot (written once at startCombat)
|
||||||
|
// must not be retroactively rewritten by equipping after the fight ends.
|
||||||
|
expect(state.combats[0].playerState).toEqual({
|
||||||
|
attack: 6,
|
||||||
|
weaponDamage: 8,
|
||||||
|
armor: 0,
|
||||||
|
potionsRemaining: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
|
||||||
|
expect(reloaded.status).toBe(result.status);
|
||||||
|
expect(reloaded.round).toBe(result.round);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/api/src/combat/combat-event-type.enum.ts
Normal file
9
apps/api/src/combat/combat-event-type.enum.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum CombatEventType {
|
||||||
|
DAMAGE = 'DAMAGE',
|
||||||
|
HEAL = 'HEAL',
|
||||||
|
DEFEND = 'DEFEND',
|
||||||
|
TELEGRAPH = 'TELEGRAPH',
|
||||||
|
INTERRUPT = 'INTERRUPT',
|
||||||
|
COMBAT_WON = 'COMBAT_WON',
|
||||||
|
COMBAT_LOST = 'COMBAT_LOST',
|
||||||
|
}
|
||||||
5
apps/api/src/combat/combat-status.enum.ts
Normal file
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
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: 'FLEE' })
|
||||||
|
.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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/api/src/combat/combat.errors.ts
Normal file
105
apps/api/src/combat/combat.errors.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type CombatErrorCode =
|
||||||
|
| 'HUNT_ENCOUNTER_NOT_FOUND'
|
||||||
|
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
||||||
|
| 'INVALID_HUNT_ENCOUNTER'
|
||||||
|
| 'CHARACTER_TRAVELLING'
|
||||||
|
| 'CHARACTER_TOO_WOUNDED'
|
||||||
|
| 'COMBAT_ALREADY_ACTIVE'
|
||||||
|
| 'COMBAT_NOT_FOUND'
|
||||||
|
| 'COMBAT_ALREADY_FINISHED'
|
||||||
|
| 'COMBAT_STATE_INVALID'
|
||||||
|
| 'COMBAT_NO_POTIONS_REMAINING';
|
||||||
|
|
||||||
|
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 characterTooWounded(): CombatDomainError {
|
||||||
|
return new CombatDomainError(
|
||||||
|
'CHARACTER_TOO_WOUNDED',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'The character is too wounded to fight.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function combatNoPotionsRemaining(): CombatDomainError {
|
||||||
|
return new CombatDomainError(
|
||||||
|
'COMBAT_NO_POTIONS_REMAINING',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'No potions remain in this combat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { characterNotFound } from '../travel/travel.errors';
|
||||||
27
apps/api/src/combat/combat.module.ts
Normal file
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 {}
|
||||||
1121
apps/api/src/combat/combat.service.spec.ts
Normal file
1121
apps/api/src/combat/combat.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
438
apps/api/src/combat/combat.service.ts
Normal file
438
apps/api/src/combat/combat.service.ts
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.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, CombatIntent } from './combat-engine.types';
|
||||||
|
import {
|
||||||
|
characterNotFound,
|
||||||
|
characterTooWounded,
|
||||||
|
characterTravelling,
|
||||||
|
combatAlreadyActive,
|
||||||
|
combatAlreadyFinished,
|
||||||
|
combatNoPotionsRemaining,
|
||||||
|
combatNotFound,
|
||||||
|
combatStateInvalid,
|
||||||
|
huntEncounterAlreadyConsumed,
|
||||||
|
huntEncounterNotFound,
|
||||||
|
invalidHuntEncounter,
|
||||||
|
} from './combat.errors';
|
||||||
|
import { CombatStatus } from './combat-status.enum';
|
||||||
|
import { CombatEvent } from './entities/combat-event.entity';
|
||||||
|
import { Combat, CombatPlayerState } from './entities/combat.entity';
|
||||||
|
|
||||||
|
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
|
||||||
|
// persistent consumable inventory.
|
||||||
|
const STARTING_POTION_COUNT = 2;
|
||||||
|
|
||||||
|
export interface CombatPlayerDto {
|
||||||
|
name: string;
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
potionsRemaining: number;
|
||||||
|
potionsMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatMonsterDto {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
artworkPath: string;
|
||||||
|
pendingIntent: CombatIntent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 characterStats: CharacterStatsService,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
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 = await this.characterStats.calculate(character, manager);
|
||||||
|
if (playerStats.currentHp < 1) {
|
||||||
|
throw characterTooWounded();
|
||||||
|
}
|
||||||
|
this.characterVitals.pause(character, playerStats.currentHp);
|
||||||
|
await characters.save(character);
|
||||||
|
|
||||||
|
const combat = combats.create({
|
||||||
|
characterId,
|
||||||
|
huntEncounterId: encounter.id,
|
||||||
|
monsterDefinitionId: monster.id,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 1,
|
||||||
|
playerMaxHp: playerStats.maxHp,
|
||||||
|
playerCurrentHp: playerStats.currentHp,
|
||||||
|
monsterMaxHp: monster.maxHp,
|
||||||
|
monsterCurrentHp: monster.maxHp,
|
||||||
|
playerState: {
|
||||||
|
attack: playerStats.attack,
|
||||||
|
weaponDamage: playerStats.weaponDamage,
|
||||||
|
armor: playerStats.armor,
|
||||||
|
potionsRemaining: STARTING_POTION_COUNT,
|
||||||
|
},
|
||||||
|
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.
|
||||||
|
const character = 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();
|
||||||
|
}
|
||||||
|
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) {
|
||||||
|
throw combatNoPotionsRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
combat.playerState = result.state.player.stats as CombatPlayerState;
|
||||||
|
combat.monsterState = result.state.monster.stats;
|
||||||
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
|
combat.completedAt = new Date();
|
||||||
|
this.characterVitals.resume(character, combat.playerCurrentHp);
|
||||||
|
await this.settleEncounter(
|
||||||
|
manager.getRepository(HuntEncounter),
|
||||||
|
combat.huntEncounterId,
|
||||||
|
combat.status,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.characterVitals.pause(character, combat.playerCurrentHp);
|
||||||
|
}
|
||||||
|
await characters.save(character);
|
||||||
|
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 [reloadedCharacter, 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, reloadedCharacter.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,
|
||||||
|
potionsRemaining: combat.playerState.potionsRemaining,
|
||||||
|
potionsMax: STARTING_POTION_COUNT,
|
||||||
|
},
|
||||||
|
monster: {
|
||||||
|
key: monster.key,
|
||||||
|
name: monster.name,
|
||||||
|
level: monster.level,
|
||||||
|
maxHp: combat.monsterMaxHp,
|
||||||
|
currentHp: combat.monsterCurrentHp,
|
||||||
|
artworkPath: monster.artworkPath,
|
||||||
|
pendingIntent: combat.monsterState.pendingAction ?? null,
|
||||||
|
},
|
||||||
|
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
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
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
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;
|
||||||
|
}
|
||||||
94
apps/api/src/combat/entities/combat.entity.ts
Normal file
94
apps/api/src/combat/entities/combat.entity.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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 { CombatIntent } from '../combat-engine.types';
|
||||||
|
import { CombatStatus } from '../combat-status.enum';
|
||||||
|
|
||||||
|
export interface CombatCombatantState {
|
||||||
|
attack: number;
|
||||||
|
armor: number;
|
||||||
|
pendingAction?: CombatIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatPlayerState extends CombatCombatantState {
|
||||||
|
weaponDamage: number;
|
||||||
|
potionsRemaining: 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
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
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,107 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateHuntingSystem1787500000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "monster_definitions" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"key" character varying(100) NOT NULL,
|
||||||
|
"name" character varying(150) NOT NULL,
|
||||||
|
"level" integer NOT NULL,
|
||||||
|
"max_hp" integer NOT NULL,
|
||||||
|
"attack" integer NOT NULL,
|
||||||
|
"armor" integer NOT NULL,
|
||||||
|
"experience_reward" integer NOT NULL,
|
||||||
|
"silver_min" integer NOT NULL,
|
||||||
|
"silver_max" integer NOT NULL,
|
||||||
|
"artwork_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_monster_definitions" PRIMARY KEY ("id")
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
"CREATE TYPE \"location_monster_encounter_type_enum\" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')",
|
||||||
|
);
|
||||||
|
await queryRunner.query(`CREATE TABLE "location_monsters" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"location_id" uuid NOT NULL,
|
||||||
|
"monster_id" uuid NOT NULL,
|
||||||
|
"weight" integer NOT NULL,
|
||||||
|
"encounter_type" "location_monster_encounter_type_enum" NOT NULL DEFAULT 'NORMAL',
|
||||||
|
"enabled" boolean NOT NULL DEFAULT true,
|
||||||
|
CONSTRAINT "PK_location_monsters" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_location_monsters_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_location_monsters_monster" FOREIGN KEY ("monster_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_monsters_location_monster"
|
||||||
|
ON "location_monsters" ("location_id", "monster_id")`);
|
||||||
|
await queryRunner.query(
|
||||||
|
"CREATE TYPE \"hunt_status_enum\" AS ENUM ('ACTIVE', 'SUPERSEDED')",
|
||||||
|
);
|
||||||
|
await queryRunner.query(`CREATE TABLE "hunts" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"character_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid NOT NULL,
|
||||||
|
"status" "hunt_status_enum" NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_hunts" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_hunts_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_hunts_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_hunt_per_character"
|
||||||
|
ON "hunts" ("character_id")
|
||||||
|
WHERE "status" = 'ACTIVE'`);
|
||||||
|
await queryRunner.query(`CREATE TABLE "hunt_encounters" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"hunt_id" uuid NOT NULL,
|
||||||
|
"monster_definition_id" uuid NOT NULL,
|
||||||
|
"position" integer NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_hunt_encounters" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_hunt_encounters_hunt" FOREIGN KEY ("hunt_id") REFERENCES "hunts"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_hunt_encounters_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_hunt_encounters_monster_definition" ON "hunt_encounters" ("monster_definition_id")',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX "IDX_hunt_encounters_monster_definition"',
|
||||||
|
);
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_hunt_encounters_hunt"');
|
||||||
|
await queryRunner.query('DROP TABLE "hunt_encounters"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_active_hunt_per_character"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_hunts_location"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_hunts_character"');
|
||||||
|
await queryRunner.query('DROP TABLE "hunts"');
|
||||||
|
await queryRunner.query('DROP TYPE "hunt_status_enum"');
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX "IDX_location_monsters_location_monster"',
|
||||||
|
);
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_location_monsters_monster"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_location_monsters_location"');
|
||||||
|
await queryRunner.query('DROP TABLE "location_monsters"');
|
||||||
|
await queryRunner.query('DROP TYPE "location_monster_encounter_type_enum"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_monster_definitions_key"');
|
||||||
|
await queryRunner.query('DROP TABLE "monster_definitions"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,82 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the local location view content to `location_definitions`.
|
||||||
|
*
|
||||||
|
* The existing `description`/`artwork_path` columns are deliberately left
|
||||||
|
* alone — the map and hunt screens still render them. Backfill defaults keep
|
||||||
|
* existing rows valid; the seed replaces them with authored content.
|
||||||
|
*/
|
||||||
|
export class CreateLocalLocationView1788700000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Existing rows fall back to their map-level content until the seed runs,
|
||||||
|
// so the view never renders an empty breadcrumb or a missing artwork.
|
||||||
|
await queryRunner.query(
|
||||||
|
'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateEquipment1789000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
|
||||||
|
await queryRunner.query(`CREATE TABLE "character_equipment" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"character_id" uuid NOT NULL,
|
||||||
|
"slot" "equipment_slot_enum" NOT NULL,
|
||||||
|
"character_item_id" uuid 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_equipment" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX "IDX_character_equipment_character_item"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX "IDX_character_equipment_character_slot"',
|
||||||
|
);
|
||||||
|
await queryRunner.query('DROP TABLE "character_equipment"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
|
||||||
|
// This fails if any row already uses one of the new values -- expected
|
||||||
|
// for a dev rollback, same tradeoff Postgres migrations always make here.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddHpRegeneration1792000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Existing characters start regenerating immediately from their current
|
||||||
|
// HP. A character whose fight is still ACTIVE keeps regeneration paused
|
||||||
|
// until that fight resolves, matching the "no combat-time regen" rule
|
||||||
|
// (persistent-hp-and-regeneration design, R4) -- this migration must not
|
||||||
|
// gift them free healing mid-fight.
|
||||||
|
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
|
||||||
|
await queryRunner.query(`UPDATE "characters" AS "character"
|
||||||
|
SET "hp_regen_since" = NULL
|
||||||
|
FROM "combats" AS "combat"
|
||||||
|
WHERE "combat"."character_id" = "character"."id"
|
||||||
|
AND "combat"."status" = 'ACTIVE'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
|
||||||
|
describe('characters.hp_regen_since schema', () => {
|
||||||
|
it('stores the regeneration anchor as a nullable timestamptz', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const column = metadata.columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(column).toBeDefined();
|
||||||
|
expect(column?.options.type).toBe('timestamptz');
|
||||||
|
expect(column?.options.nullable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||||
|
|
||||||
|
describe('character_equipment schema', () => {
|
||||||
|
it('stores slot as a non-nullable equipment_slot_enum column', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const column = metadata.columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(column).toBeDefined();
|
||||||
|
expect(column?.options.type).toBe('enum');
|
||||||
|
expect(column?.options.enumName).toBe('equipment_slot_enum');
|
||||||
|
expect(column?.options.nullable).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces one equipped item per character per slot', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const index = metadata.indices.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === CharacterEquipment &&
|
||||||
|
candidate.columns?.includes('characterId') &&
|
||||||
|
candidate.columns?.includes('slot'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(index).toBeDefined();
|
||||||
|
const indexMetadata = index as typeof index & {
|
||||||
|
options?: { unique?: boolean };
|
||||||
|
unique?: boolean;
|
||||||
|
};
|
||||||
|
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const index = metadata.indices.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === CharacterEquipment &&
|
||||||
|
candidate.columns?.length === 1 &&
|
||||||
|
candidate.columns?.includes('characterItemId'),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,27 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
||||||
|
import { CombatEventType } from '../../combat/combat-event-type.enum';
|
||||||
|
|
||||||
|
describe('combat_events.type enum', () => {
|
||||||
|
it('includes the Playable Slice 0.6 event types', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const column = metadata.columns.find(
|
||||||
|
(candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(column).toBeDefined();
|
||||||
|
expect(column?.options.enum).toBe(CombatEventType);
|
||||||
|
expect(Object.values(CombatEventType)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'DAMAGE',
|
||||||
|
'HEAL',
|
||||||
|
'DEFEND',
|
||||||
|
'TELEGRAPH',
|
||||||
|
'INTERRUPT',
|
||||||
|
'COMBAT_WON',
|
||||||
|
'COMBAT_LOST',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||||
|
import { Hunt } from '../../hunting/entities/hunt.entity';
|
||||||
|
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
|
||||||
|
|
||||||
|
describe('hunting system schema', () => {
|
||||||
|
it('maps the monster definition key index and relationship foreign keys explicitly', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const monsterKeyIndex = metadata.indices.find((index) => {
|
||||||
|
const metadataIndex = index as typeof index & {
|
||||||
|
options?: { unique?: boolean };
|
||||||
|
unique?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
index.target === MonsterDefinition &&
|
||||||
|
index.columns?.includes('key') &&
|
||||||
|
(metadataIndex.options?.unique ?? metadataIndex.unique) === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(monsterKeyIndex).toBeDefined();
|
||||||
|
|
||||||
|
const relations = metadata.relations.filter((relation) =>
|
||||||
|
[LocationMonster, Hunt, HuntEncounter].includes(
|
||||||
|
relation.target as typeof LocationMonster,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const joinColumns = metadata.joinColumns
|
||||||
|
.filter((joinColumn) =>
|
||||||
|
[LocationMonster, Hunt, HuntEncounter].includes(
|
||||||
|
joinColumn.target as typeof LocationMonster,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((joinColumn) => joinColumn.name);
|
||||||
|
|
||||||
|
expect(joinColumns).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'location_id',
|
||||||
|
'monster_id',
|
||||||
|
'character_id',
|
||||||
|
'location_id',
|
||||||
|
'hunt_id',
|
||||||
|
'monster_definition_id',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
relations.map((relation) => ({
|
||||||
|
onDelete: relation.options.onDelete,
|
||||||
|
propertyName: relation.propertyName,
|
||||||
|
target: relation.target,
|
||||||
|
})),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
propertyName: 'location',
|
||||||
|
target: LocationMonster,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
propertyName: 'monster',
|
||||||
|
target: LocationMonster,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
propertyName: 'character',
|
||||||
|
target: Hunt,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
propertyName: 'location',
|
||||||
|
target: Hunt,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
propertyName: 'monster',
|
||||||
|
target: HuntEncounter,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deliberate deviation from the codebase's usual RESTRICT: HuntEncounter
|
||||||
|
// rows are owned/composed by their parent Hunt and must be removed along
|
||||||
|
// with it, so this relation uses CASCADE. Asserted explicitly so a
|
||||||
|
// future refactor can't silently change it.
|
||||||
|
const huntEncounterToHunt = relations.find(
|
||||||
|
(relation) =>
|
||||||
|
relation.target === HuntEncounter && relation.propertyName === 'hunt',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(huntEncounterToHunt).toBeDefined();
|
||||||
|
expect(huntEncounterToHunt?.options.onDelete).toBe('CASCADE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
|
||||||
|
const MIGRATION_SQL = readFileSync(
|
||||||
|
join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
function columnNames(target: unknown): string[] {
|
||||||
|
return getMetadataArgsStorage()
|
||||||
|
.columns.filter((column) => column.target === target)
|
||||||
|
.map((column) => column.options.name)
|
||||||
|
.filter((name): name is string => typeof name === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local location view schema', () => {
|
||||||
|
const newLocationColumns = [
|
||||||
|
'region_name',
|
||||||
|
'region_tier_label',
|
||||||
|
'location_type',
|
||||||
|
'local_description',
|
||||||
|
'local_artwork_path',
|
||||||
|
'local_points_of_interest',
|
||||||
|
'local_primary_actions',
|
||||||
|
'local_reward_preview',
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(newLocationColumns)(
|
||||||
|
'maps %s on LocationDefinition',
|
||||||
|
(name: string) => {
|
||||||
|
expect(columnNames(LocationDefinition)).toContain(name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
`ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
|
||||||
|
);
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
`ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the monster icon path in both the entity and the migration', () => {
|
||||||
|
expect(columnNames(MonsterDefinition)).toContain('icon_path');
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map-level columns untouched so the world screen is unaffected', () => {
|
||||||
|
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
|
||||||
|
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
|
||||||
|
expect(columnNames(LocationDefinition)).toEqual(
|
||||||
|
expect.arrayContaining(['description', 'artwork_path', 'region_key']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills existing rows so no location renders an empty breadcrumb', () => {
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'UPDATE "location_definitions" SET "region_name" = "region_key"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
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
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';
|
||||||
210
apps/api/src/database/seeds/local-location.content.ts
Normal file
210
apps/api/src/database/seeds/local-location.content.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
LocationRewardPreviewContent,
|
||||||
|
LocationType,
|
||||||
|
} from '../../world/local-location.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authored local-view content per location (plan §7–§11).
|
||||||
|
*
|
||||||
|
* Coordinates are percentages of the artwork box, so a hotspot stays on the
|
||||||
|
* same painted detail at every viewport width. They are tuned against the real
|
||||||
|
* artwork in `apps/web/public/images/backgrounds/`, not against the
|
||||||
|
* composition mockup in `docs/references/`.
|
||||||
|
*/
|
||||||
|
export interface LocalLocationContent {
|
||||||
|
regionName: string;
|
||||||
|
regionTierLabel: string;
|
||||||
|
locationType: LocationType;
|
||||||
|
localDescription: string;
|
||||||
|
localArtworkPath: string;
|
||||||
|
localPointsOfInterest: LocationPointOfInterestContent[];
|
||||||
|
localPrimaryActions: LocationPrimaryActionContent[];
|
||||||
|
localRewardPreview: LocationRewardPreviewContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localDescription:
|
||||||
|
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
// Anchored to painted detail in `Aschestrasse.png`: the burning horizon down
|
||||||
|
// the road, the standing gravestone on the left verge, the cracked stones in
|
||||||
|
// the near foreground, and the broken cart wheel on the right.
|
||||||
|
localPointsOfInterest: [
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 57,
|
||||||
|
yPercent: 33,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 40,
|
||||||
|
yPercent: 82,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verdächtige Spuren',
|
||||||
|
resultText:
|
||||||
|
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-abandoned-wagon',
|
||||||
|
title: 'Verlassener Wagen',
|
||||||
|
actionLabel: 'Durchsuchen',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 86,
|
||||||
|
yPercent: 70,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verlassener Wagen',
|
||||||
|
resultText:
|
||||||
|
'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wounded-scout',
|
||||||
|
title: 'Verwundeter Kundschafter',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 17,
|
||||||
|
yPercent: 62,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verwundeter Kundschafter',
|
||||||
|
resultText:
|
||||||
|
'„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPrimaryActions: [
|
||||||
|
{
|
||||||
|
key: 'start-hunt',
|
||||||
|
label: 'Jagd beginnen',
|
||||||
|
description: 'Im Gebiet jagen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'investigate-tracks',
|
||||||
|
label: 'Spuren untersuchen',
|
||||||
|
description: 'Hinweise finden',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'inspect-tracks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-surroundings',
|
||||||
|
label: 'Umgebung durchsuchen',
|
||||||
|
description: 'Beute finden',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'search-abandoned-wagon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-map',
|
||||||
|
label: 'Zur Karte',
|
||||||
|
description: 'Gebiet wechseln',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Only categories the loot tables on this road actually back: silver and
|
||||||
|
// experience from every kill, gear from the raiders, pelts from the beasts.
|
||||||
|
// Named items stay out — the view may not promise a drop the roll does not
|
||||||
|
// guarantee (spec §8, "Mögliche Belohnungen").
|
||||||
|
localRewardPreview: [
|
||||||
|
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
|
||||||
|
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
|
||||||
|
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
|
||||||
|
{ key: 'material', label: 'Material', iconKey: 'material' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localDescription:
|
||||||
|
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
localPointsOfInterest: [
|
||||||
|
{
|
||||||
|
key: 'gate-notice',
|
||||||
|
title: 'Aushangtafel',
|
||||||
|
actionLabel: 'Lesen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 22,
|
||||||
|
yPercent: 42,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Aushangtafel',
|
||||||
|
resultText:
|
||||||
|
'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gate-watch',
|
||||||
|
title: 'Torwache',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 45,
|
||||||
|
yPercent: 52,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Torwache',
|
||||||
|
resultText:
|
||||||
|
'„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'south-road',
|
||||||
|
title: 'Straße nach Süden',
|
||||||
|
actionLabel: 'Zur Karte',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
xPercent: 66,
|
||||||
|
yPercent: 72,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPrimaryActions: [
|
||||||
|
{
|
||||||
|
key: 'talk-to-watch',
|
||||||
|
label: 'Wache ansprechen',
|
||||||
|
description: 'Lage erfragen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'gate-watch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'read-notice',
|
||||||
|
label: 'Aushang lesen',
|
||||||
|
description: 'Hinweise finden',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'gate-notice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-map',
|
||||||
|
label: 'Zur Karte',
|
||||||
|
description: 'Gebiet wechseln',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localRewardPreview: [],
|
||||||
|
};
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
|
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
|
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||||
|
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||||
|
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||||
|
import { CharacterItem } from '../../items/entities/character-item.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 { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||||
|
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
@@ -9,6 +18,10 @@ type Row = Record<string, unknown>;
|
|||||||
const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
|
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
|
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||||
|
const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||||
|
const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||||
|
|
||||||
class InMemoryRepository {
|
class InMemoryRepository {
|
||||||
readonly rows: Row[] = [];
|
readonly rows: Row[] = [];
|
||||||
@@ -61,18 +74,26 @@ function createDataSource(
|
|||||||
locationRepository: InMemoryRepository,
|
locationRepository: InMemoryRepository,
|
||||||
connectionRepository: InMemoryRepository,
|
connectionRepository: InMemoryRepository,
|
||||||
characterRepository: InMemoryRepository,
|
characterRepository: InMemoryRepository,
|
||||||
|
monsterRepository: InMemoryRepository,
|
||||||
|
locationMonsterRepository: InMemoryRepository,
|
||||||
|
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
): DataSource {
|
): DataSource {
|
||||||
return {
|
return {
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
if (entity === LocationDefinition) {
|
if (entity === LocationDefinition) return locationRepository;
|
||||||
return locationRepository;
|
if (entity === LocationConnection) return connectionRepository;
|
||||||
}
|
if (entity === Character) return characterRepository;
|
||||||
if (entity === LocationConnection) {
|
if (entity === MonsterDefinition) return monsterRepository;
|
||||||
return connectionRepository;
|
if (entity === LocationMonster) return locationMonsterRepository;
|
||||||
}
|
if (entity === ItemDefinition) return itemRepository;
|
||||||
if (entity === Character) {
|
if (entity === LootTable) return lootTableRepository;
|
||||||
return characterRepository;
|
if (entity === LootTableEntry) return lootEntryRepository;
|
||||||
}
|
if (entity === CharacterItem) return characterItemRepository;
|
||||||
|
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||||
|
|
||||||
throw new Error('Unexpected repository');
|
throw new Error('Unexpected repository');
|
||||||
}),
|
}),
|
||||||
@@ -84,10 +105,14 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const locationRepository = new InMemoryRepository();
|
const locationRepository = new InMemoryRepository();
|
||||||
const connectionRepository = new InMemoryRepository();
|
const connectionRepository = new InMemoryRepository();
|
||||||
const characterRepository = new InMemoryRepository();
|
const characterRepository = new InMemoryRepository();
|
||||||
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource(
|
||||||
locationRepository,
|
locationRepository,
|
||||||
connectionRepository,
|
connectionRepository,
|
||||||
characterRepository,
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
@@ -136,13 +161,174 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
experience: 39,
|
experience: 39,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||||
|
expect(monsterRepository.rows).toHaveLength(4);
|
||||||
|
expect(monsterRepository.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'road-bandit',
|
||||||
|
name: 'Straßenräuber',
|
||||||
|
level: 2,
|
||||||
|
maxHp: 75,
|
||||||
|
attack: 9,
|
||||||
|
armor: 5,
|
||||||
|
experienceReward: 16,
|
||||||
|
silverMin: 9,
|
||||||
|
silverMax: 15,
|
||||||
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'wild-road-dog',
|
||||||
|
name: 'Verwilderter Straßenhund',
|
||||||
|
level: 1,
|
||||||
|
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||||
|
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'charred-looter',
|
||||||
|
name: 'Verkohlter Plünderer',
|
||||||
|
level: 2,
|
||||||
|
artworkPath: '/images/monsters/charred-looter.png',
|
||||||
|
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: ASH_RAT_MONSTER_ID,
|
||||||
|
weight: 40,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: WILD_ROAD_DOG_MONSTER_ID,
|
||||||
|
weight: 30,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: ROAD_BANDIT_MONSTER_ID,
|
||||||
|
weight: 20,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: CHARRED_LOOTER_MONSTER_ID,
|
||||||
|
weight: 10,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
['locationId', 'monsterId'],
|
||||||
|
);
|
||||||
|
expect(locationMonsterRepository.rows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
const burnedRoad = locationRepository.rows.find(
|
||||||
|
(row) => row.key === 'burned-road',
|
||||||
|
) as Row;
|
||||||
|
|
||||||
|
expect(burnedRoad).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(burnedRoad.localDescription).toContain(
|
||||||
|
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
|
||||||
|
'hunt-area',
|
||||||
|
'inspect-tracks',
|
||||||
|
'search-abandoned-wagon',
|
||||||
|
'wounded-scout',
|
||||||
|
]);
|
||||||
|
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
|
||||||
|
'HUNT',
|
||||||
|
'INVESTIGATE',
|
||||||
|
'SEARCH',
|
||||||
|
'NPC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const primaryActions = burnedRoad.localPrimaryActions as {
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
expect(primaryActions.map((action) => action.label)).toEqual([
|
||||||
|
'Jagd beginnen',
|
||||||
|
'Spuren untersuchen',
|
||||||
|
'Umgebung durchsuchen',
|
||||||
|
'Zur Karte',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the Südtor its own local content so a second location needs no new component', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
const southGate = locationRepository.rows.find(
|
||||||
|
(row) => row.key === 'south-gate',
|
||||||
|
) as Row;
|
||||||
|
|
||||||
|
expect(southGate).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(southGate.localPointsOfInterest).toHaveLength(3);
|
||||||
|
// A transition location offers no hunt, so no HUNT hotspot may appear.
|
||||||
|
expect(
|
||||||
|
(southGate.localPointsOfInterest as { type: string }[]).some(
|
||||||
|
(poi) => poi.type === 'HUNT',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves existing location IDs and uses them for the directed connections', async () => {
|
it('preserves existing location IDs and uses them for the directed connections', async () => {
|
||||||
const locationRepository = new InMemoryRepository();
|
const locationRepository = new InMemoryRepository();
|
||||||
const connectionRepository = new InMemoryRepository();
|
const connectionRepository = new InMemoryRepository();
|
||||||
const characterRepository = new InMemoryRepository();
|
const characterRepository = new InMemoryRepository();
|
||||||
const persistedSouthGateId = '30000000-0000-4000-8000-000000000001';
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
|
const persistedSouthGateId = '40000000-0000-4000-8000-000000000001';
|
||||||
locationRepository.rows.push({
|
locationRepository.rows.push({
|
||||||
id: persistedSouthGateId,
|
id: persistedSouthGateId,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
@@ -152,6 +338,8 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
locationRepository,
|
locationRepository,
|
||||||
connectionRepository,
|
connectionRepository,
|
||||||
characterRepository,
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
@@ -188,4 +376,205 @@ 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 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const connectionRepository = new InMemoryRepository();
|
||||||
|
const characterRepository = new InMemoryRepository();
|
||||||
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
|
const characterItemRepository = new InMemoryRepository();
|
||||||
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
connectionRepository,
|
||||||
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
characterItemRepository,
|
||||||
|
characterEquipmentRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
expect(characterItemRepository.rows).toHaveLength(1);
|
||||||
|
expect(characterItemRepository.rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||||
|
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
slot: 'WEAPON',
|
||||||
|
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never re-equips the starting sword once the player has equipped different gear', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const connectionRepository = new InMemoryRepository();
|
||||||
|
const characterRepository = new InMemoryRepository();
|
||||||
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
|
const characterItemRepository = new InMemoryRepository();
|
||||||
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
connectionRepository,
|
||||||
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
characterItemRepository,
|
||||||
|
characterEquipmentRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
// Simulate the player having equipped earned loot instead.
|
||||||
|
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||||
|
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
|
||||||
|
'earned-bandit-blade-item-id',
|
||||||
|
);
|
||||||
|
expect(characterItemRepository.rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const connectionRepository = new InMemoryRepository();
|
||||||
|
const characterRepository = new InMemoryRepository();
|
||||||
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
|
const characterItemRepository = new InMemoryRepository();
|
||||||
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
connectionRepository,
|
||||||
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
characterItemRepository,
|
||||||
|
characterEquipmentRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the demo character having already looted a worn-short-sword
|
||||||
|
// naturally, under a DB-generated id that differs from the seed's
|
||||||
|
// stable literal constant.
|
||||||
|
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
|
||||||
|
characterItemRepository.rows.push({
|
||||||
|
id: naturallyLootedItemId,
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
expect(characterItemRepository.rows).toHaveLength(1);
|
||||||
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||||
|
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
slot: 'WEAPON',
|
||||||
|
characterItemId: naturallyLootedItemId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,39 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
import {
|
||||||
|
DEMO_CHARACTER_ID,
|
||||||
|
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||||
|
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||||
|
} from '../../demo/demo-character.constants';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||||
|
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||||
|
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||||
|
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 { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
import { BURNED_ROAD_ID, SOUTH_GATE_ID } from './vertical-slice.constants';
|
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||||
|
import {
|
||||||
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
ITEM_IDS,
|
||||||
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
} from './item.constants';
|
||||||
|
import {
|
||||||
|
BURNED_ROAD_LOCAL_CONTENT,
|
||||||
|
SOUTH_GATE_LOCAL_CONTENT,
|
||||||
|
} from './local-location.content';
|
||||||
|
import {
|
||||||
|
ASH_RAT_MONSTER_ID,
|
||||||
|
BURNED_ROAD_ID,
|
||||||
|
CHARRED_LOOTER_MONSTER_ID,
|
||||||
|
ROAD_BANDIT_MONSTER_ID,
|
||||||
|
SOUTH_GATE_ID,
|
||||||
|
WILD_ROAD_DOG_MONSTER_ID,
|
||||||
|
} from './vertical-slice.constants';
|
||||||
|
|
||||||
export async function seedVisibleVerticalSlice(
|
export async function seedVisibleVerticalSlice(
|
||||||
dataSource: DataSource,
|
dataSource: DataSource,
|
||||||
@@ -11,6 +41,11 @@ export async function seedVisibleVerticalSlice(
|
|||||||
const locationRepository = dataSource.getRepository(LocationDefinition);
|
const locationRepository = dataSource.getRepository(LocationDefinition);
|
||||||
const connectionRepository = dataSource.getRepository(LocationConnection);
|
const connectionRepository = dataSource.getRepository(LocationConnection);
|
||||||
const characterRepository = dataSource.getRepository(Character);
|
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 = [
|
const locations = [
|
||||||
{
|
{
|
||||||
@@ -26,6 +61,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
...SOUTH_GATE_LOCAL_CONTENT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: BURNED_ROAD_ID,
|
id: BURNED_ROAD_ID,
|
||||||
@@ -40,17 +76,14 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
...BURNED_ROAD_LOCAL_CONTENT,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let southGateId = SOUTH_GATE_ID;
|
|
||||||
let burnedRoadId = BURNED_ROAD_ID;
|
|
||||||
|
|
||||||
|
const locationIds = new Map<string, string>();
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const existing = await locationRepository.findOneBy({
|
const existing = await locationRepository.findOneBy({ key: location.key });
|
||||||
key: location.key,
|
|
||||||
});
|
|
||||||
const { id, key, ...definition } = location;
|
const { id, key, ...definition } = location;
|
||||||
const persistedId = existing?.id ?? id;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await locationRepository.update(existing.id, definition);
|
await locationRepository.update(existing.id, definition);
|
||||||
@@ -58,13 +91,12 @@ export async function seedVisibleVerticalSlice(
|
|||||||
await locationRepository.insert(location);
|
await locationRepository.insert(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'south-gate') {
|
locationIds.set(key, existing?.id ?? id);
|
||||||
southGateId = persistedId;
|
|
||||||
} else {
|
|
||||||
burnedRoadId = persistedId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
||||||
|
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
||||||
|
|
||||||
await connectionRepository.upsert(
|
await connectionRepository.upsert(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -85,6 +117,118 @@ export async function seedVisibleVerticalSlice(
|
|||||||
['fromLocationId', 'toLocationId'],
|
['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,
|
||||||
|
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',
|
||||||
|
iconPath: '/images/combat/icons/ash-rat-128.png',
|
||||||
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: WILD_ROAD_DOG_MONSTER_ID,
|
||||||
|
key: 'wild-road-dog',
|
||||||
|
name: 'Verwilderter Straßenhund',
|
||||||
|
level: 1,
|
||||||
|
maxHp: 55,
|
||||||
|
attack: 7,
|
||||||
|
armor: 0,
|
||||||
|
experienceReward: 11,
|
||||||
|
silverMin: 5,
|
||||||
|
silverMax: 9,
|
||||||
|
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||||
|
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||||
|
// Shares the beast table: both are scorched road animals that leave a
|
||||||
|
// pelt behind. A table of its own waits for content that differs.
|
||||||
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ROAD_BANDIT_MONSTER_ID,
|
||||||
|
key: 'road-bandit',
|
||||||
|
name: 'Straßenräuber',
|
||||||
|
level: 2,
|
||||||
|
maxHp: 75,
|
||||||
|
attack: 9,
|
||||||
|
armor: 5,
|
||||||
|
experienceReward: 16,
|
||||||
|
silverMin: 9,
|
||||||
|
silverMax: 15,
|
||||||
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
|
iconPath: '/images/combat/icons/road-bandit-128.png',
|
||||||
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CHARRED_LOOTER_MONSTER_ID,
|
||||||
|
key: 'charred-looter',
|
||||||
|
name: 'Verkohlter Plünderer',
|
||||||
|
level: 2,
|
||||||
|
maxHp: 85,
|
||||||
|
attack: 11,
|
||||||
|
armor: 6,
|
||||||
|
experienceReward: 20,
|
||||||
|
silverMin: 12,
|
||||||
|
silverMax: 19,
|
||||||
|
artworkPath: '/images/monsters/charred-looter.png',
|
||||||
|
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||||
|
// Shares the raider table: same gear, taken from the same caravans.
|
||||||
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const monsterIds = new Map<string, string>();
|
||||||
|
for (const monster of monsters) {
|
||||||
|
const existingMonster = await monsterRepository.findOneBy({
|
||||||
|
key: monster.key,
|
||||||
|
});
|
||||||
|
const { id, key, ...definition } = monster;
|
||||||
|
|
||||||
|
if (existingMonster) {
|
||||||
|
await monsterRepository.update(existingMonster.id, definition);
|
||||||
|
} else {
|
||||||
|
await monsterRepository.insert(monster);
|
||||||
|
}
|
||||||
|
|
||||||
|
monsterIds.set(key, existingMonster?.id ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weights read as "how often you meet this on the road". They also drive the
|
||||||
|
// location's danger rating, which is computed from the weighted average of
|
||||||
|
// the pool rather than from its single worst entry.
|
||||||
|
const encounterWeights: Readonly<Record<string, number>> = {
|
||||||
|
'ash-rat': 40,
|
||||||
|
'wild-road-dog': 30,
|
||||||
|
'road-bandit': 20,
|
||||||
|
'charred-looter': 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await locationMonsterRepository.upsert(
|
||||||
|
Object.entries(encounterWeights).map(([key, weight]) => ({
|
||||||
|
locationId: burnedRoadId,
|
||||||
|
monsterId: monsterIds.get(key) as string,
|
||||||
|
weight,
|
||||||
|
encounterType: EncounterType.NORMAL,
|
||||||
|
enabled: true,
|
||||||
|
})),
|
||||||
|
['locationId', 'monsterId'],
|
||||||
|
);
|
||||||
|
|
||||||
const existing = await characterRepository.findOneBy({
|
const existing = await characterRepository.findOneBy({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
});
|
});
|
||||||
@@ -95,10 +239,50 @@ export async function seedVisibleVerticalSlice(
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
hpRegenSince: new Date(),
|
||||||
currentLocationId: southGateId,
|
currentLocationId: southGateId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const characterItemRepository = dataSource.getRepository(CharacterItem);
|
||||||
|
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
|
||||||
|
|
||||||
|
// Starting loadout is weapon-only -- no starter armor piece exists in
|
||||||
|
// content yet -- so the demo character's effective armor (sum of equipped
|
||||||
|
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
|
||||||
|
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
|
||||||
|
// says to preserve the existing demo balance "as closely as the
|
||||||
|
// implemented content allows" and explicitly forbids fabricating a full
|
||||||
|
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
|
||||||
|
const existingStartingSword = await characterItemRepository.findOneBy({
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||||
|
});
|
||||||
|
const startingSwordItemId =
|
||||||
|
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
|
||||||
|
if (!existingStartingSword) {
|
||||||
|
await characterItemRepository.insert({
|
||||||
|
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
slot: EquipmentSlot.WEAPON,
|
||||||
|
});
|
||||||
|
if (!existingWeaponEquipment) {
|
||||||
|
await characterEquipmentRepository.insert({
|
||||||
|
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||||
|
characterId: DEMO_CHARACTER_ID,
|
||||||
|
slot: EquipmentSlot.WEAPON,
|
||||||
|
characterItemId: startingSwordItemId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
|
||||||
|
'10000000-0000-4000-8000-000000000002';
|
||||||
|
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
|
||||||
|
'10000000-0000-4000-8000-000000000003';
|
||||||
|
|||||||
6
apps/api/src/equipment/dto/equip-item.dto.ts
Normal file
6
apps/api/src/equipment/dto/equip-item.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class EquipItemDto {
|
||||||
|
@IsUUID()
|
||||||
|
characterItemId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||||
|
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One equipped item in one slot for one character (spec §12).
|
||||||
|
*
|
||||||
|
* `characterItemId` must belong to `characterId` — enforced by
|
||||||
|
* `EquipmentService`, never by the client (spec §13).
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'character_equipment' })
|
||||||
|
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class CharacterEquipment {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id', type: 'uuid' })
|
||||||
|
characterId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'slot',
|
||||||
|
type: 'enum',
|
||||||
|
enum: EquipmentSlot,
|
||||||
|
enumName: 'equipment_slot_enum',
|
||||||
|
})
|
||||||
|
slot!: EquipmentSlot;
|
||||||
|
|
||||||
|
@Column({ name: 'character_item_id', type: 'uuid' })
|
||||||
|
characterItemId!: string;
|
||||||
|
|
||||||
|
@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(() => CharacterItem, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'character_item_id' })
|
||||||
|
characterItem!: CharacterItem;
|
||||||
|
}
|
||||||
19
apps/api/src/equipment/equipment.controller.ts
Normal file
19
apps/api/src/equipment/equipment.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { EquipItemDto } from './dto/equip-item.dto';
|
||||||
|
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
|
||||||
|
|
||||||
|
@Controller('equipment')
|
||||||
|
export class EquipmentController {
|
||||||
|
constructor(private readonly equipmentService: EquipmentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getEquipment(): Promise<EquipmentResponseDto> {
|
||||||
|
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
|
||||||
|
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/api/src/equipment/equipment.errors.ts
Normal file
71
apps/api/src/equipment/equipment.errors.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type EquipmentErrorCode =
|
||||||
|
| 'CHARACTER_ITEM_NOT_FOUND'
|
||||||
|
| 'ITEM_NOT_OWNED'
|
||||||
|
| 'ITEM_NOT_EQUIPPABLE'
|
||||||
|
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
|
||||||
|
| 'INVALID_EQUIPMENT_SLOT'
|
||||||
|
| 'CHARACTER_IN_COMBAT';
|
||||||
|
|
||||||
|
export class EquipmentDomainError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: EquipmentErrorCode,
|
||||||
|
status: HttpStatus,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super({ statusCode: status, code, message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function characterItemNotFound(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'CHARACTER_ITEM_NOT_FOUND',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
'This item could not be found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemNotOwned(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'ITEM_NOT_OWNED',
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
'This item does not belong to the character.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemNotEquippable(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'ITEM_NOT_EQUIPPABLE',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
'This item cannot be equipped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemLevelRequirementNotMet(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"The character does not meet this item's level requirement.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive: slot is always derived from the item definition server-side, so
|
||||||
|
// this is unreachable in practice (spec §27 still names it explicitly).
|
||||||
|
export function invalidEquipmentSlot(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'INVALID_EQUIPMENT_SLOT',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
'This item does not target a valid equipment slot.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function characterInCombat(): EquipmentDomainError {
|
||||||
|
return new EquipmentDomainError(
|
||||||
|
'CHARACTER_IN_COMBAT',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'Equipment cannot be changed during an active combat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { characterNotFound } from '../travel/travel.errors';
|
||||||
21
apps/api/src/equipment/equipment.module.ts
Normal file
21
apps/api/src/equipment/equipment.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CharactersModule } from '../characters/characters.module';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { Combat } from '../combat/entities/combat.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { CharacterEquipment } from './entities/character-equipment.entity';
|
||||||
|
import { EquipmentController } from './equipment.controller';
|
||||||
|
import { EquipmentService } from './equipment.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
|
||||||
|
CharactersModule,
|
||||||
|
],
|
||||||
|
controllers: [EquipmentController],
|
||||||
|
providers: [EquipmentService],
|
||||||
|
exports: [EquipmentService],
|
||||||
|
})
|
||||||
|
export class EquipmentModule {}
|
||||||
475
apps/api/src/equipment/equipment.service.spec.ts
Normal file
475
apps/api/src/equipment/equipment.service.spec.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||||
|
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 { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { ItemType } from '../items/item-type.enum';
|
||||||
|
import { CharacterEquipment } from './entities/character-equipment.entity';
|
||||||
|
import { EquipmentDomainError } from './equipment.errors';
|
||||||
|
import { EquipmentService } from './equipment.service';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
|
||||||
|
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
|
||||||
|
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
|
||||||
|
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
characters: Character[];
|
||||||
|
itemDefinitions: ItemDefinition[];
|
||||||
|
characterItems: CharacterItem[];
|
||||||
|
characterEquipment: CharacterEquipment[];
|
||||||
|
combats: Combat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRepository<T extends { id: string }> {
|
||||||
|
constructor(
|
||||||
|
private readonly state: State,
|
||||||
|
private readonly target: EntityTarget<T>,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findOne(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
relations?: Record<string, unknown>;
|
||||||
|
lock?: { mode: string };
|
||||||
|
}): Promise<T | null> {
|
||||||
|
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
|
||||||
|
return Promise.resolve(row ? this.withRelations(row, options.relations) : 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>; relations?: Record<string, unknown> }): Promise<T[]> {
|
||||||
|
const matched = this.rows().filter((row) => this.matches(row, options.where));
|
||||||
|
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 withRelations(row: T, relations?: Record<string, unknown>): T {
|
||||||
|
if (!relations) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const copy = { ...row } as T & Record<string, unknown>;
|
||||||
|
if (this.target === CharacterItem && relations['itemDefinition']) {
|
||||||
|
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
|
||||||
|
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
|
||||||
|
}
|
||||||
|
if (this.target === CharacterEquipment && relations['characterItem']) {
|
||||||
|
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
|
||||||
|
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
|
||||||
|
copy['characterItem'] = characterItem
|
||||||
|
? {
|
||||||
|
...characterItem,
|
||||||
|
itemDefinition: this.state.itemDefinitions.find(
|
||||||
|
(d) => d.id === characterItem.itemDefinitionId,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
return copy as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rows(): T[] {
|
||||||
|
if (this.target === Character) return this.state.characters as T[];
|
||||||
|
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
|
||||||
|
if (this.target === CharacterItem) return this.state.characterItems as T[];
|
||||||
|
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
|
||||||
|
if (this.target === Combat) return this.state.combats 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 FakeDataSource {
|
||||||
|
private readonly idCounters = new Map<string, number>();
|
||||||
|
constructor(public state: State) {}
|
||||||
|
|
||||||
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||||
|
return new FakeRepository(this.state, target, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
|
||||||
|
return work({
|
||||||
|
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
|
||||||
|
this.getRepository(target),
|
||||||
|
} as unknown as EntityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextId(targetName: string): string {
|
||||||
|
const next = (this.idCounters.get(targetName) ?? 0) + 1;
|
||||||
|
this.idCounters.set(targetName, next);
|
||||||
|
return `${targetName.toLowerCase()}-generated-${next}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
|
||||||
|
return {
|
||||||
|
id: 'def-worn-sword',
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: '',
|
||||||
|
type: ItemType.WEAPON,
|
||||||
|
equipmentSlot: EquipmentSlot.WEAPON,
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
tier: 1,
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
sellPrice: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
} as ItemDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(overrides: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
baseHp: 100,
|
||||||
|
baseAttack: 6,
|
||||||
|
currentHp: 100,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(state: Partial<State> = {}) {
|
||||||
|
const fullState: State = {
|
||||||
|
characters: [character()],
|
||||||
|
itemDefinitions: [],
|
||||||
|
characterItems: [],
|
||||||
|
characterEquipment: [],
|
||||||
|
combats: [],
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
const dataSource = new FakeDataSource(fullState);
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const characterStats = new CharacterStatsService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const service = new EquipmentService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
return { state: fullState, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectEquipmentDomainError(promise: Promise<unknown>, code: string): Promise<void> {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(EquipmentDomainError);
|
||||||
|
if (!(error instanceof EquipmentDomainError)) {
|
||||||
|
throw new Error('Expected EquipmentDomainError');
|
||||||
|
}
|
||||||
|
expect(error.code).toBe(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EquipmentService', () => {
|
||||||
|
describe('equip', () => {
|
||||||
|
it('equips an owned weapon into the WEAPON slot', async () => {
|
||||||
|
const wornSword = itemDefinition();
|
||||||
|
const { state, service } = createHarness({
|
||||||
|
itemDefinitions: [wornSword],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: wornSword.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
|
||||||
|
|
||||||
|
expect(result.slots.WEAPON).toEqual({
|
||||||
|
characterItemId: WORN_SWORD_ITEM_ID,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.characterEquipment).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
|
||||||
|
const wornSword = itemDefinition();
|
||||||
|
const banditBlade = itemDefinition({
|
||||||
|
id: 'def-bandit-blade',
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
});
|
||||||
|
const { state, service } = createHarness({
|
||||||
|
itemDefinitions: [wornSword, banditBlade],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: wornSword.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
{
|
||||||
|
id: BANDIT_BLADE_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: banditBlade.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
characterEquipment: [
|
||||||
|
{
|
||||||
|
id: 'equip-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
slot: EquipmentSlot.WEAPON,
|
||||||
|
characterItemId: WORN_SWORD_ITEM_ID,
|
||||||
|
} as CharacterEquipment,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||||
|
|
||||||
|
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
|
||||||
|
expect(state.characterEquipment).toHaveLength(1);
|
||||||
|
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping an item owned by a different character', async () => {
|
||||||
|
const wornSword = itemDefinition();
|
||||||
|
const { service } = createHarness({
|
||||||
|
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||||
|
itemDefinitions: [wornSword],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: OTHER_CHARACTER_ID,
|
||||||
|
itemDefinitionId: wornSword.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectEquipmentDomainError(
|
||||||
|
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
|
||||||
|
'ITEM_NOT_OWNED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping an unknown CharacterItem id', async () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
|
||||||
|
await expectEquipmentDomainError(
|
||||||
|
service.equip(CHARACTER_ID, 'unknown-item'),
|
||||||
|
'CHARACTER_ITEM_NOT_FOUND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping an item above the character level', async () => {
|
||||||
|
const highLevelHelm = itemDefinition({
|
||||||
|
id: BANDIT_HOOD_ITEM_ID,
|
||||||
|
key: 'bandit-hood',
|
||||||
|
equipmentSlot: EquipmentSlot.HEAD,
|
||||||
|
requiredLevel: 5,
|
||||||
|
});
|
||||||
|
const { service } = createHarness({
|
||||||
|
itemDefinitions: [highLevelHelm],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: BANDIT_HOOD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: highLevelHelm.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectEquipmentDomainError(
|
||||||
|
service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID),
|
||||||
|
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping a non-equippable item', async () => {
|
||||||
|
const material = itemDefinition({
|
||||||
|
id: 'def-ash-pelt',
|
||||||
|
key: 'ash-pelt',
|
||||||
|
type: ItemType.MATERIAL,
|
||||||
|
equipmentSlot: null,
|
||||||
|
});
|
||||||
|
const { service } = createHarness({
|
||||||
|
itemDefinitions: [material],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: 'item-ash-pelt',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: material.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectEquipmentDomainError(
|
||||||
|
service.equip(CHARACTER_ID, 'item-ash-pelt'),
|
||||||
|
'ITEM_NOT_EQUIPPABLE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
|
||||||
|
const wornSword = itemDefinition();
|
||||||
|
const banditBlade = itemDefinition({
|
||||||
|
id: 'def-bandit-blade',
|
||||||
|
key: 'bandit-blade',
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
});
|
||||||
|
const { state, service } = createHarness({
|
||||||
|
itemDefinitions: [wornSword, banditBlade],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: wornSword.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
{
|
||||||
|
id: BANDIT_BLADE_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: banditBlade.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sequential repeats stand in for the concurrent case here (a real race
|
||||||
|
// is guarded by the DB's UNIQUE(character_id, slot) constraint from
|
||||||
|
// Task 1, which a synchronous fake repository cannot exercise).
|
||||||
|
await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
|
||||||
|
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||||
|
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
|
||||||
|
|
||||||
|
expect(state.characterEquipment).toHaveLength(1);
|
||||||
|
expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equipping while the character has an active combat', async () => {
|
||||||
|
const wornSword = itemDefinition();
|
||||||
|
const { service } = createHarness({
|
||||||
|
itemDefinitions: [wornSword],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: WORN_SWORD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: wornSword.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectEquipmentDomainError(
|
||||||
|
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
|
||||||
|
'CHARACTER_IN_COMBAT',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
|
||||||
|
const bonusHpHelm = itemDefinition({
|
||||||
|
id: 'def-bonus-hp-helm',
|
||||||
|
key: 'bonus-hp-helm',
|
||||||
|
name: 'Gepolsterter Helm',
|
||||||
|
equipmentSlot: EquipmentSlot.HEAD,
|
||||||
|
bonusHp: 20,
|
||||||
|
weaponDamage: 0,
|
||||||
|
});
|
||||||
|
const { state, service } = createHarness({
|
||||||
|
characters: [
|
||||||
|
character({
|
||||||
|
currentHp: 90,
|
||||||
|
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
itemDefinitions: [bonusHpHelm],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: BANDIT_HOOD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: bonusHpHelm.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
|
||||||
|
|
||||||
|
expect(state.characters[0].currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEquipment', () => {
|
||||||
|
it('returns empty slots and base stats when nothing is equipped', async () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
|
||||||
|
const result = await service.getEquipment(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(result.slots).toEqual({
|
||||||
|
WEAPON: null,
|
||||||
|
HEAD: null,
|
||||||
|
CHEST: null,
|
||||||
|
HANDS: null,
|
||||||
|
LEGS: null,
|
||||||
|
FEET: null,
|
||||||
|
AMULET: null,
|
||||||
|
});
|
||||||
|
expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
174
apps/api/src/equipment/equipment.service.ts
Normal file
174
apps/api/src/equipment/equipment.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||||
|
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 { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { CharacterEquipment } from './entities/character-equipment.entity';
|
||||||
|
import {
|
||||||
|
characterInCombat,
|
||||||
|
characterItemNotFound,
|
||||||
|
characterNotFound,
|
||||||
|
itemLevelRequirementNotMet,
|
||||||
|
itemNotEquippable,
|
||||||
|
itemNotOwned,
|
||||||
|
} from './equipment.errors';
|
||||||
|
|
||||||
|
export interface EquipmentSlotItemDto {
|
||||||
|
characterItemId: string;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EquipmentSlotsDto = Record<EquipmentSlot, EquipmentSlotItemDto | null>;
|
||||||
|
|
||||||
|
export interface EquipmentStatsDto {
|
||||||
|
maxHp: number;
|
||||||
|
attack: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
armor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentResponseDto {
|
||||||
|
slots: EquipmentSlotsDto;
|
||||||
|
stats: EquipmentStatsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EquipmentService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly characterStats: CharacterStatsService,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
|
||||||
|
const character = await this.dataSource
|
||||||
|
.getRepository(Character)
|
||||||
|
.findOneBy({ id: characterId });
|
||||||
|
if (!character) {
|
||||||
|
throw characterNotFound();
|
||||||
|
}
|
||||||
|
return this.buildResponse(character, this.dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equips (or replaces) one slot for `characterId` with `characterItemId`
|
||||||
|
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
|
||||||
|
* being overwritten, never deleted (spec §15).
|
||||||
|
*/
|
||||||
|
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
|
const combats = manager.getRepository(Combat);
|
||||||
|
const characterItems = manager.getRepository(CharacterItem);
|
||||||
|
const equipmentRepo = manager.getRepository(CharacterEquipment);
|
||||||
|
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw characterNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCombat = await combats.findOne({
|
||||||
|
where: { characterId, status: CombatStatus.ACTIVE },
|
||||||
|
});
|
||||||
|
if (activeCombat) {
|
||||||
|
throw characterInCombat();
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterItem = await characterItems.findOne({
|
||||||
|
where: { id: characterItemId },
|
||||||
|
relations: { itemDefinition: true },
|
||||||
|
});
|
||||||
|
if (!characterItem) {
|
||||||
|
throw characterItemNotFound();
|
||||||
|
}
|
||||||
|
if (characterItem.characterId !== characterId) {
|
||||||
|
throw itemNotOwned();
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = characterItem.itemDefinition;
|
||||||
|
if (!definition.equipmentSlot) {
|
||||||
|
throw itemNotEquippable();
|
||||||
|
}
|
||||||
|
if (definition.requiredLevel > character.level) {
|
||||||
|
throw itemLevelRequirementNotMet();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsBeforeChange = await this.characterStats.calculate(character, manager);
|
||||||
|
this.characterVitals.settle(character, statsBeforeChange.maxHp);
|
||||||
|
await characters.save(character);
|
||||||
|
|
||||||
|
const existing = await equipmentRepo.findOne({
|
||||||
|
where: { characterId, slot: definition.equipmentSlot },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existing.characterItemId = characterItem.id;
|
||||||
|
await equipmentRepo.save(existing);
|
||||||
|
} else {
|
||||||
|
await equipmentRepo.save(
|
||||||
|
equipmentRepo.create({
|
||||||
|
characterId,
|
||||||
|
slot: definition.equipmentSlot,
|
||||||
|
characterItemId: characterItem.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildResponse(character, manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildResponse(
|
||||||
|
character: Character,
|
||||||
|
scope: RepositoryScope,
|
||||||
|
): Promise<EquipmentResponseDto> {
|
||||||
|
const equipped = await scope.getRepository(CharacterEquipment).find({
|
||||||
|
where: { characterId: character.id },
|
||||||
|
relations: { characterItem: { itemDefinition: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = Object.fromEntries(
|
||||||
|
Object.values(EquipmentSlot).map((slot) => [slot, null]),
|
||||||
|
) as EquipmentSlotsDto;
|
||||||
|
|
||||||
|
for (const row of equipped) {
|
||||||
|
const definition = row.characterItem.itemDefinition;
|
||||||
|
slots[row.slot] = {
|
||||||
|
characterItemId: row.characterItemId,
|
||||||
|
item: {
|
||||||
|
key: definition.key,
|
||||||
|
name: definition.name,
|
||||||
|
rarity: definition.rarity,
|
||||||
|
iconPath: definition.iconPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await this.characterStats.calculate(character, scope);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slots,
|
||||||
|
stats: {
|
||||||
|
maxHp: stats.maxHp,
|
||||||
|
attack: stats.attack,
|
||||||
|
weaponDamage: stats.weaponDamage,
|
||||||
|
armor: stats.armor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/api/src/hunting/danger-rating.spec.ts
Normal file
74
apps/api/src/hunting/danger-rating.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { calculateDangerRating, DangerRating } from './danger-rating';
|
||||||
|
|
||||||
|
// Demo character seed stats (Task 3): baseAttack: 6, baseHp: 100, no armor.
|
||||||
|
// power(character) = 6*4 + 0*2 + floor(100/5) = 24 + 0 + 20 = 44
|
||||||
|
const CHARACTER = { attack: 6, armor: 0, hp: 100 };
|
||||||
|
const CHARACTER_POWER = 44;
|
||||||
|
|
||||||
|
describe('calculateDangerRating', () => {
|
||||||
|
it('rates the seeded Aschenratte as MATCH', () => {
|
||||||
|
// Aschenratte: attack 5, armor 0, maxHp 45
|
||||||
|
// power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29
|
||||||
|
// ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH
|
||||||
|
const monster = { attack: 5, armor: 0, hp: 45 };
|
||||||
|
|
||||||
|
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates the seeded Straßenräuber as STRONG', () => {
|
||||||
|
// Straßenräuber: attack 9, armor 5, maxHp 75
|
||||||
|
// power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61
|
||||||
|
// ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG
|
||||||
|
const monster = { attack: 9, armor: 5, hp: 75 };
|
||||||
|
|
||||||
|
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.STRONG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates a monster just below the WEAK/MATCH boundary as WEAK', () => {
|
||||||
|
// Hand-picked monster: attack 4, armor 4, hp 20
|
||||||
|
// power(monster) = 4*4 + 4*2 + floor(20/5) = 16 + 8 + 4 = 28
|
||||||
|
// ratio = 28 / 44 = 0.6363636...
|
||||||
|
// WEAK/MATCH boundary is ratio < 0.65, i.e. power < 0.65 * 44 = 28.6.
|
||||||
|
// 28 is the largest integer power below that boundary -> WEAK.
|
||||||
|
// (One power higher, 29, is the Aschenratte case above, which is MATCH.)
|
||||||
|
const monster = { attack: 4, armor: 4, hp: 20 };
|
||||||
|
|
||||||
|
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.WEAK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates a monster just at the STRONG/VERY_DANGEROUS boundary as VERY_DANGEROUS', () => {
|
||||||
|
// Hand-picked monster: attack 15, armor 5, hp 25
|
||||||
|
// power(monster) = 15*4 + 5*2 + floor(25/5) = 60 + 10 + 5 = 75
|
||||||
|
// ratio = 75 / 44 = 1.7045454...
|
||||||
|
// STRONG/VERY_DANGEROUS boundary is ratio < 1.7, i.e. power < 1.7 * 44 = 74.8.
|
||||||
|
// 75 is the smallest integer power at/above that boundary -> VERY_DANGEROUS.
|
||||||
|
// (One power lower, 74, gives ratio 74/44 = 1.6818..., which is STRONG.)
|
||||||
|
const monster = { attack: 15, armor: 5, hp: 25 };
|
||||||
|
|
||||||
|
expect(calculateDangerRating(CHARACTER, monster)).toBe(
|
||||||
|
DangerRating.VERY_DANGEROUS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates a monster just at the VERY_DANGEROUS/DEADLY boundary as DEADLY', () => {
|
||||||
|
// Hand-picked monster: attack 20, armor 10, hp 10
|
||||||
|
// power(monster) = 20*4 + 10*2 + floor(10/5) = 80 + 20 + 2 = 102
|
||||||
|
// ratio = 102 / 44 = 2.3181818...
|
||||||
|
// VERY_DANGEROUS/DEADLY boundary is ratio < 2.3, i.e. power < 2.3 * 44 = 101.2.
|
||||||
|
// 102 is the smallest integer power at/above that boundary -> DEADLY.
|
||||||
|
// (One power lower, 101, gives ratio 101/44 = 2.2954..., which is VERY_DANGEROUS.)
|
||||||
|
const monster = { attack: 20, armor: 10, hp: 10 };
|
||||||
|
|
||||||
|
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.DEADLY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that CHARACTER_POWER documented above matches the formula.
|
||||||
|
describe('CHARACTER_POWER sanity check', () => {
|
||||||
|
it('matches power(CHARACTER) as computed by the same formula', () => {
|
||||||
|
const power =
|
||||||
|
CHARACTER.attack * 4 + CHARACTER.armor * 2 + Math.floor(CHARACTER.hp / 5);
|
||||||
|
|
||||||
|
expect(power).toBe(CHARACTER_POWER);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/api/src/hunting/danger-rating.ts
Normal file
31
apps/api/src/hunting/danger-rating.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export enum DangerRating {
|
||||||
|
WEAK = 'WEAK',
|
||||||
|
MATCH = 'MATCH',
|
||||||
|
STRONG = 'STRONG',
|
||||||
|
VERY_DANGEROUS = 'VERY_DANGEROUS',
|
||||||
|
DEADLY = 'DEADLY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatantStats {
|
||||||
|
attack: number;
|
||||||
|
armor: number;
|
||||||
|
hp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// power(entity) = attack*4 + armor*2 + floor(hp/5) — a deliberately small,
|
||||||
|
// provisional server-side stand-in for a future CombatPower system (none
|
||||||
|
// exists yet in this codebase). ratio = monsterPower / characterPower.
|
||||||
|
export function calculateDangerRating(
|
||||||
|
character: CombatantStats,
|
||||||
|
monster: CombatantStats,
|
||||||
|
): DangerRating {
|
||||||
|
const power = (stats: CombatantStats) =>
|
||||||
|
stats.attack * 4 + stats.armor * 2 + Math.floor(stats.hp / 5);
|
||||||
|
const ratio = power(monster) / power(character);
|
||||||
|
|
||||||
|
if (ratio < 0.65) return DangerRating.WEAK;
|
||||||
|
if (ratio < 1.0) return DangerRating.MATCH;
|
||||||
|
if (ratio < 1.7) return DangerRating.STRONG;
|
||||||
|
if (ratio < 2.3) return DangerRating.VERY_DANGEROUS;
|
||||||
|
return DangerRating.DEADLY;
|
||||||
|
}
|
||||||
51
apps/api/src/hunting/entities/hunt-encounter.entity.ts
Normal file
51
apps/api/src/hunting/entities/hunt-encounter.entity.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
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' })
|
||||||
|
export class HuntEncounter {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'hunt_id', type: 'uuid' })
|
||||||
|
huntId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'monster_definition_id', type: 'uuid' })
|
||||||
|
monsterDefinitionId!: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
// CASCADE (unlike the other FKs in this file, which use RESTRICT): a
|
||||||
|
// HuntEncounter is owned/composed by its parent Hunt and has no
|
||||||
|
// independent lifecycle, so it should be removed along with its Hunt.
|
||||||
|
@ManyToOne(() => Hunt, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'hunt_id' })
|
||||||
|
hunt!: Hunt;
|
||||||
|
|
||||||
|
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'monster_definition_id' })
|
||||||
|
monster!: MonsterDefinition;
|
||||||
|
}
|
||||||
42
apps/api/src/hunting/entities/hunt.entity.ts
Normal file
42
apps/api/src/hunting/entities/hunt.entity.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
import { HuntStatus } from '../hunt-status.enum';
|
||||||
|
|
||||||
|
@Entity({ name: 'hunts' })
|
||||||
|
export class Hunt {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id', type: 'uuid' })
|
||||||
|
characterId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'location_id', type: 'uuid' })
|
||||||
|
locationId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: HuntStatus,
|
||||||
|
enumName: 'hunt_status_enum',
|
||||||
|
})
|
||||||
|
status!: HuntStatus;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character!: Character;
|
||||||
|
|
||||||
|
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location!: LocationDefinition;
|
||||||
|
}
|
||||||
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
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',
|
||||||
|
}
|
||||||
4
apps/api/src/hunting/hunt-status.enum.ts
Normal file
4
apps/api/src/hunting/hunt-status.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum HuntStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
SUPERSEDED = 'SUPERSEDED',
|
||||||
|
}
|
||||||
90
apps/api/src/hunting/hunting.controller.spec.ts
Normal file
90
apps/api/src/hunting/hunting.controller.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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 { HuntingController } from './hunting.controller';
|
||||||
|
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, getActiveHunt },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication<App>();
|
||||||
|
configureApplication(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => {
|
||||||
|
const huntResult = {
|
||||||
|
id: 'hunt-1',
|
||||||
|
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
|
||||||
|
encounters: [],
|
||||||
|
};
|
||||||
|
startHunt.mockResolvedValue(huntResult);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/hunts')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
18
apps/api/src/hunting/hunting.controller.ts
Normal file
18
apps/api/src/hunting/hunting.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { HuntResultDto, HuntingService } from './hunting.service';
|
||||||
|
|
||||||
|
@Controller('hunts')
|
||||||
|
export class HuntingController {
|
||||||
|
constructor(private readonly huntingService: HuntingService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
startHunt(): Promise<HuntResultDto> {
|
||||||
|
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('active')
|
||||||
|
getActiveHunt(): Promise<HuntResultDto | null> {
|
||||||
|
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/hunting/hunting.errors.ts
Normal file
37
apps/api/src/hunting/hunting.errors.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class HuntingDomainError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super({ statusCode: status, code, message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function huntingNotAvailable(): HuntingDomainError {
|
||||||
|
return new HuntingDomainError(
|
||||||
|
'HUNTING_NOT_AVAILABLE',
|
||||||
|
400,
|
||||||
|
'Hunting is not available at the current location.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function characterTravelling(): HuntingDomainError {
|
||||||
|
return new HuntingDomainError(
|
||||||
|
'CHARACTER_TRAVELLING',
|
||||||
|
409,
|
||||||
|
'The character cannot hunt while travelling.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noHuntEncountersAvailable(): HuntingDomainError {
|
||||||
|
return new HuntingDomainError(
|
||||||
|
'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
||||||
|
409,
|
||||||
|
'No encounters are currently available at this location.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { characterNotFound } from '../travel/travel.errors';
|
||||||
30
apps/api/src/hunting/hunting.module.ts
Normal file
30
apps/api/src/hunting/hunting.module.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { TravelModule } from '../travel/travel.module';
|
||||||
|
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 '../shared/random-source';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
MonsterDefinition,
|
||||||
|
LocationMonster,
|
||||||
|
Hunt,
|
||||||
|
HuntEncounter,
|
||||||
|
Character,
|
||||||
|
]),
|
||||||
|
TravelModule,
|
||||||
|
],
|
||||||
|
controllers: [HuntingController],
|
||||||
|
providers: [
|
||||||
|
HuntingService,
|
||||||
|
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class HuntingModule {}
|
||||||
726
apps/api/src/hunting/hunting.service.spec.ts
Normal file
726
apps/api/src/hunting/hunting.service.spec.ts
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { EncounterType } from '../monsters/entities/encounter-type.enum';
|
||||||
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { TravelService } from '../travel/travel.service';
|
||||||
|
import { TravelStatus } from '../travel/travel-status.enum';
|
||||||
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||||
|
import { DangerRating } from './danger-rating';
|
||||||
|
import { Hunt } from './entities/hunt.entity';
|
||||||
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||||
|
import { 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 '../shared/random-source';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
|
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
|
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
|
||||||
|
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
|
||||||
|
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
|
||||||
|
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
|
||||||
|
|
||||||
|
interface FakeState {
|
||||||
|
characters: Character[];
|
||||||
|
locationMonsters: LocationMonster[];
|
||||||
|
hunts: Hunt[];
|
||||||
|
huntEncounters: HuntEncounter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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,
|
||||||
|
private readonly target: EntityTarget<T>,
|
||||||
|
private readonly inTransaction: boolean,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findOne(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
lock?: { mode: string };
|
||||||
|
}): Promise<T | null> {
|
||||||
|
if (options.lock) {
|
||||||
|
if (!this.inTransaction) {
|
||||||
|
throw new Error('Pessimistic locks require a transaction');
|
||||||
|
}
|
||||||
|
this.dataSource.locks.push({
|
||||||
|
target: this.target,
|
||||||
|
mode: options.lock.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
this.rows().find((row) => this.matches(row, options.where)) ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
find(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
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 {
|
||||||
|
return { ...values } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(entity: T): Promise<T> {
|
||||||
|
if (this.dataSource.failSaveTarget === this.target) {
|
||||||
|
throw new Error(`Failed to save ${this.targetName()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entity.id) {
|
||||||
|
entity.id = this.dataSource.nextId(this.targetName());
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this.rows();
|
||||||
|
const index = rows.findIndex((row) => row.id === entity.id);
|
||||||
|
if (index === -1) {
|
||||||
|
rows.push(entity);
|
||||||
|
} else {
|
||||||
|
rows[index] = entity;
|
||||||
|
}
|
||||||
|
return Promise.resolve(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(where: Partial<T>, partial: Partial<T>): Promise<void> {
|
||||||
|
for (const row of this.rows()) {
|
||||||
|
if (this.matches(row, where)) {
|
||||||
|
Object.assign(row, partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rows(): T[] {
|
||||||
|
if (this.target === Character) {
|
||||||
|
return this.state.characters as T[];
|
||||||
|
}
|
||||||
|
if (this.target === LocationMonster) {
|
||||||
|
return this.state.locationMonsters as T[];
|
||||||
|
}
|
||||||
|
if (this.target === Hunt) {
|
||||||
|
return this.state.hunts as T[];
|
||||||
|
}
|
||||||
|
if (this.target === HuntEncounter) {
|
||||||
|
return this.state.huntEncounters as T[];
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported repository ${this.targetName()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(row: T, where: Partial<T>): boolean {
|
||||||
|
return Object.entries(where).every(
|
||||||
|
([key, value]) => row[key as keyof T] === value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetName(): string {
|
||||||
|
return typeof this.target === 'function'
|
||||||
|
? this.target.name
|
||||||
|
: 'EntitySchema';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeEntityManager {
|
||||||
|
constructor(
|
||||||
|
private readonly state: FakeState,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||||
|
return new FakeRepository(this.state, target, true, this.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDataSource {
|
||||||
|
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
|
||||||
|
failSaveTarget?: EntityTarget<unknown>;
|
||||||
|
private readonly idCounters = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(public state: FakeState) {}
|
||||||
|
|
||||||
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||||
|
return new FakeRepository(this.state, target, false, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(
|
||||||
|
work: (manager: EntityManager) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const draft = structuredClone(this.state);
|
||||||
|
const result = await work(
|
||||||
|
new FakeEntityManager(draft, this) as unknown as EntityManager,
|
||||||
|
);
|
||||||
|
this.state = draft;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextId(targetName: string): string {
|
||||||
|
const next = (this.idCounters.get(targetName) ?? 0) + 1;
|
||||||
|
this.idCounters.set(targetName, next);
|
||||||
|
return `${targetName.toLowerCase()}-generated-${next}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function huntingLocation(): LocationDefinition {
|
||||||
|
return {
|
||||||
|
id: HUNTING_LOCATION_ID,
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Strasse',
|
||||||
|
description: 'A burned road.',
|
||||||
|
regionKey: 'ashen-fields',
|
||||||
|
minRecommendedLevel: 1,
|
||||||
|
maxRecommendedLevel: 2,
|
||||||
|
dangerLevel: 1,
|
||||||
|
isSafe: false,
|
||||||
|
huntingEnabled: true,
|
||||||
|
artworkPath: '/assets/locations/burned-road.webp',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
characters: [],
|
||||||
|
outgoingConnections: [],
|
||||||
|
incomingConnections: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeLocation(): LocationDefinition {
|
||||||
|
return {
|
||||||
|
id: SAFE_LOCATION_ID,
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Suedtor von Graufurt',
|
||||||
|
description: 'A safe gate.',
|
||||||
|
regionKey: 'ashen-fields',
|
||||||
|
minRecommendedLevel: 1,
|
||||||
|
maxRecommendedLevel: 2,
|
||||||
|
dangerLevel: 1,
|
||||||
|
isSafe: true,
|
||||||
|
huntingEnabled: false,
|
||||||
|
artworkPath: '/assets/locations/south-gate.webp',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
characters: [],
|
||||||
|
outgoingConnections: [],
|
||||||
|
incomingConnections: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(currentLocation: LocationDefinition): Character {
|
||||||
|
return {
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
|
baseHp: 100,
|
||||||
|
baseAttack: 6,
|
||||||
|
currentHp: 100,
|
||||||
|
currentLocationId: currentLocation.id,
|
||||||
|
currentLocation,
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function monsterDefinition(
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
name: string,
|
||||||
|
overrides: Partial<MonsterDefinition> = {},
|
||||||
|
): MonsterDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
level: 1,
|
||||||
|
maxHp: 20,
|
||||||
|
attack: 3,
|
||||||
|
armor: 0,
|
||||||
|
experienceReward: 10,
|
||||||
|
silverMin: 1,
|
||||||
|
silverMax: 3,
|
||||||
|
artworkPath: `/assets/monsters/${key}.webp`,
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationMonster(
|
||||||
|
id: string,
|
||||||
|
locationId: string,
|
||||||
|
monster: MonsterDefinition,
|
||||||
|
weight: number,
|
||||||
|
enabled = true,
|
||||||
|
): LocationMonster {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
locationId,
|
||||||
|
monsterId: monster.id,
|
||||||
|
weight,
|
||||||
|
encounterType: EncounterType.NORMAL,
|
||||||
|
enabled,
|
||||||
|
monster,
|
||||||
|
} as LocationMonster;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(): FakeState {
|
||||||
|
return {
|
||||||
|
characters: [character(safeLocation())],
|
||||||
|
locationMonsters: [],
|
||||||
|
hunts: [],
|
||||||
|
huntEncounters: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRandomSource(values: number[]): RandomSource {
|
||||||
|
const queue = [...values];
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
const value = queue.shift();
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error('fakeRandomSource exhausted its canned values');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTravelService(
|
||||||
|
overrides: Partial<TravelService> = {},
|
||||||
|
): TravelService {
|
||||||
|
return {
|
||||||
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as TravelService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
options: {
|
||||||
|
state?: FakeState;
|
||||||
|
travelService?: TravelService;
|
||||||
|
randomSource?: RandomSource;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const state = options.state ?? createState();
|
||||||
|
const dataSource = new FakeDataSource(state);
|
||||||
|
const travelService = options.travelService ?? fakeTravelService();
|
||||||
|
const randomSource = options.randomSource ?? fakeRandomSource([]);
|
||||||
|
const service = new HuntingService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
travelService,
|
||||||
|
randomSource,
|
||||||
|
);
|
||||||
|
return { dataSource, service, travelService, randomSource };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectHuntingDomainError(
|
||||||
|
promise: Promise<unknown>,
|
||||||
|
code: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(HuntingDomainError);
|
||||||
|
if (!(error instanceof HuntingDomainError)) {
|
||||||
|
throw new Error('Expected HuntingDomainError');
|
||||||
|
}
|
||||||
|
expect(error.code).toBe(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HuntingService', () => {
|
||||||
|
it('rejects hunting at a location where hunting is disabled', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expectHuntingDomainError(
|
||||||
|
service.startHunt(CHARACTER_ID),
|
||||||
|
'HUNTING_NOT_AVAILABLE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a valid hunt with exactly three saved encounters', async () => {
|
||||||
|
const monsterA = monsterDefinition(
|
||||||
|
MONSTER_A_ID,
|
||||||
|
'aschenratte',
|
||||||
|
'Aschenratte',
|
||||||
|
);
|
||||||
|
const monsterB = monsterDefinition(
|
||||||
|
MONSTER_B_ID,
|
||||||
|
'strassenraeuber',
|
||||||
|
'Straßenräuber',
|
||||||
|
);
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.locationMonsters = [
|
||||||
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
||||||
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||||
|
];
|
||||||
|
const { dataSource, service } = createService({
|
||||||
|
state,
|
||||||
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.startHunt(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(result.encounters).toHaveLength(3);
|
||||||
|
const encounterIds = new Set(result.encounters.map((e) => e.id));
|
||||||
|
expect(encounterIds.size).toBe(3);
|
||||||
|
expect(result.location).toEqual({
|
||||||
|
id: HUNTING_LOCATION_ID,
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Strasse',
|
||||||
|
});
|
||||||
|
expect(dataSource.state.hunts).toHaveLength(1);
|
||||||
|
expect(dataSource.state.hunts[0]).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
locationId: HUNTING_LOCATION_ID,
|
||||||
|
status: HuntStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
expect(dataSource.state.huntEncounters).toHaveLength(3);
|
||||||
|
expect(dataSource.locks).toEqual([
|
||||||
|
{ target: Character, mode: 'pessimistic_write' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects hunting while the character is still travelling', async () => {
|
||||||
|
const travelService = fakeTravelService({
|
||||||
|
completeTravelIfDue: jest.fn().mockResolvedValue({
|
||||||
|
status: TravelStatus.TRAVELLING,
|
||||||
|
originLocation: { id: SAFE_LOCATION_ID, key: 'south-gate', name: 'x' },
|
||||||
|
targetLocation: {
|
||||||
|
id: HUNTING_LOCATION_ID,
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'y',
|
||||||
|
},
|
||||||
|
startedAt: new Date(),
|
||||||
|
arrivesAt: new Date(),
|
||||||
|
}) as unknown as TravelService['completeTravelIfDue'],
|
||||||
|
});
|
||||||
|
const { service } = createService({ travelService });
|
||||||
|
|
||||||
|
await expectHuntingDomainError(
|
||||||
|
service.startHunt(CHARACTER_ID),
|
||||||
|
'CHARACTER_TRAVELLING',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects hunting when the location has no enabled encounter pool', async () => {
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.locationMonsters = [];
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expectHuntingDomainError(
|
||||||
|
service.startHunt(CHARACTER_ID),
|
||||||
|
'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks monsters deterministically from canned RandomSource rolls', async () => {
|
||||||
|
const monsterA = monsterDefinition(
|
||||||
|
MONSTER_A_ID,
|
||||||
|
'aschenratte',
|
||||||
|
'Aschenratte',
|
||||||
|
);
|
||||||
|
const monsterB = monsterDefinition(
|
||||||
|
MONSTER_B_ID,
|
||||||
|
'strassenraeuber',
|
||||||
|
'Straßenräuber',
|
||||||
|
);
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.locationMonsters = [
|
||||||
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
||||||
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||||
|
];
|
||||||
|
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
|
||||||
|
const { dataSource, service } = createService({
|
||||||
|
state,
|
||||||
|
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.startHunt(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(result.encounters.map((e) => e.monster.key)).toEqual([
|
||||||
|
'aschenratte',
|
||||||
|
'strassenraeuber',
|
||||||
|
'aschenratte',
|
||||||
|
]);
|
||||||
|
const persisted = [...dataSource.state.huntEncounters].sort(
|
||||||
|
(a, b) => a.position - b.position,
|
||||||
|
);
|
||||||
|
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
|
||||||
|
MONSTER_A_ID,
|
||||||
|
MONSTER_B_ID,
|
||||||
|
MONSTER_A_ID,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersedes the previous active hunt when a new hunt is started', async () => {
|
||||||
|
const monsterA = monsterDefinition(
|
||||||
|
MONSTER_A_ID,
|
||||||
|
'aschenratte',
|
||||||
|
'Aschenratte',
|
||||||
|
);
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.locationMonsters = [
|
||||||
|
locationMonster(
|
||||||
|
LOCATION_MONSTER_A_ID,
|
||||||
|
HUNTING_LOCATION_ID,
|
||||||
|
monsterA,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const { dataSource, service } = createService({
|
||||||
|
state,
|
||||||
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.startHunt(CHARACTER_ID);
|
||||||
|
await service.startHunt(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(dataSource.state.hunts).toHaveLength(2);
|
||||||
|
const [firstHunt, secondHunt] = dataSource.state.hunts;
|
||||||
|
expect(firstHunt.status).toBe(HuntStatus.SUPERSEDED);
|
||||||
|
expect(secondHunt.status).toBe(HuntStatus.ACTIVE);
|
||||||
|
expect(firstHunt.id).not.toBe(secondHunt.id);
|
||||||
|
expect(dataSource.locks).toEqual([
|
||||||
|
{ target: Character, mode: 'pessimistic_write' },
|
||||||
|
{ target: Character, mode: 'pessimistic_write' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each encounter its own id matching the monster rolled for that slot', async () => {
|
||||||
|
const monsterA = monsterDefinition(
|
||||||
|
MONSTER_A_ID,
|
||||||
|
'aschenratte',
|
||||||
|
'Aschenratte',
|
||||||
|
);
|
||||||
|
const monsterB = monsterDefinition(
|
||||||
|
MONSTER_B_ID,
|
||||||
|
'strassenraeuber',
|
||||||
|
'Straßenräuber',
|
||||||
|
);
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.locationMonsters = [
|
||||||
|
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
|
||||||
|
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
|
||||||
|
];
|
||||||
|
const { dataSource, service } = createService({
|
||||||
|
state,
|
||||||
|
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.startHunt(CHARACTER_ID);
|
||||||
|
|
||||||
|
const encounters = [...dataSource.state.huntEncounters].sort(
|
||||||
|
(a, b) => a.position - b.position,
|
||||||
|
);
|
||||||
|
const ids = encounters.map((e) => e.id);
|
||||||
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID);
|
||||||
|
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID);
|
||||||
|
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a danger rating per encounter from the real monster stats', async () => {
|
||||||
|
const weakMonster = monsterDefinition(
|
||||||
|
MONSTER_A_ID,
|
||||||
|
'aschenratte',
|
||||||
|
'Aschenratte',
|
||||||
|
{
|
||||||
|
attack: 1,
|
||||||
|
armor: 0,
|
||||||
|
maxHp: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const state = createState();
|
||||||
|
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||||
|
state.characters[0].currentLocation = huntingLocation();
|
||||||
|
state.characters[0].baseAttack = 6;
|
||||||
|
state.characters[0].baseHp = 100;
|
||||||
|
state.locationMonsters = [
|
||||||
|
locationMonster(
|
||||||
|
LOCATION_MONSTER_A_ID,
|
||||||
|
HUNTING_LOCATION_ID,
|
||||||
|
weakMonster,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const { service } = createService({
|
||||||
|
state,
|
||||||
|
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.startHunt(CHARACTER_ID);
|
||||||
|
|
||||||
|
for (const encounter of result.encounters) {
|
||||||
|
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
249
apps/api/src/hunting/hunting.service.ts
Normal file
249
apps/api/src/hunting/hunting.service.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import type { LocationSummary } from '../travel/travel.service';
|
||||||
|
import { TravelService } from '../travel/travel.service';
|
||||||
|
import { TravelStatus } from '../travel/travel-status.enum';
|
||||||
|
import { calculateDangerRating, DangerRating } from './danger-rating';
|
||||||
|
import { Hunt } from './entities/hunt.entity';
|
||||||
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||||
|
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
||||||
|
import { HuntStatus } from './hunt-status.enum';
|
||||||
|
import {
|
||||||
|
characterNotFound,
|
||||||
|
characterTravelling,
|
||||||
|
huntingNotAvailable,
|
||||||
|
noHuntEncountersAvailable,
|
||||||
|
} from './hunting.errors';
|
||||||
|
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||||
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
|
||||||
|
export interface MonsterSummary {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
artworkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuntEncounterDto {
|
||||||
|
id: string;
|
||||||
|
monster: MonsterSummary;
|
||||||
|
dangerRating: DangerRating;
|
||||||
|
status: HuntEncounterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuntResultDto {
|
||||||
|
id: string;
|
||||||
|
location: LocationSummary;
|
||||||
|
encounters: HuntEncounterDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENCOUNTER_COUNT = 3;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HuntingService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly travelService: TravelService,
|
||||||
|
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async startHunt(characterId: string): Promise<HuntResultDto> {
|
||||||
|
const travel = await this.travelService.completeTravelIfDue(characterId);
|
||||||
|
if (travel.status === TravelStatus.TRAVELLING) {
|
||||||
|
throw characterTravelling();
|
||||||
|
}
|
||||||
|
|
||||||
|
const characters = this.dataSource.getRepository(Character);
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
relations: { currentLocation: true },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
// completeTravelIfDue already validated the character exists; this
|
||||||
|
// guard only protects against a pathological race and satisfies the
|
||||||
|
// type checker (currentLocation would otherwise be possibly undefined).
|
||||||
|
throw characterNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!character.currentLocation.huntingEnabled) {
|
||||||
|
throw huntingNotAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationMonsters = this.dataSource.getRepository(LocationMonster);
|
||||||
|
const pool = await locationMonsters.find({
|
||||||
|
where: { locationId: character.currentLocationId, enabled: true },
|
||||||
|
relations: { monster: true },
|
||||||
|
});
|
||||||
|
if (pool.length === 0) {
|
||||||
|
throw noHuntEncountersAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const txCharacters = manager.getRepository(Character);
|
||||||
|
const txHunts = manager.getRepository(Hunt);
|
||||||
|
const txEncounters = manager.getRepository(HuntEncounter);
|
||||||
|
|
||||||
|
const lockedCharacter = await this.lockCharacter(
|
||||||
|
txCharacters,
|
||||||
|
characterId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await txHunts.update(
|
||||||
|
{ characterId, status: HuntStatus.ACTIVE },
|
||||||
|
{ status: HuntStatus.SUPERSEDED },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hunt = txHunts.create({
|
||||||
|
characterId,
|
||||||
|
locationId: lockedCharacter.currentLocationId,
|
||||||
|
status: HuntStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
await txHunts.save(hunt);
|
||||||
|
|
||||||
|
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT);
|
||||||
|
|
||||||
|
const encounterDtos: HuntEncounterDto[] = [];
|
||||||
|
for (let position = 0; position < pickedMonsters.length; position += 1) {
|
||||||
|
const monster = pickedMonsters[position];
|
||||||
|
const encounter = txEncounters.create({
|
||||||
|
huntId: hunt.id,
|
||||||
|
monsterDefinitionId: monster.id,
|
||||||
|
position,
|
||||||
|
status: HuntEncounterStatus.AVAILABLE,
|
||||||
|
});
|
||||||
|
await txEncounters.save(encounter);
|
||||||
|
|
||||||
|
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hunt.id,
|
||||||
|
location: this.toLocationSummary(character.currentLocation),
|
||||||
|
encounters: encounterDtos,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the first entry whose cumulative weight exceeds the roll
|
||||||
|
* (roll < cumulative). Pure and deterministic given a RandomSource, so
|
||||||
|
* it is trivially unit-testable with canned `next()` values.
|
||||||
|
*/
|
||||||
|
private rollEncounters(
|
||||||
|
pool: LocationMonster[],
|
||||||
|
count: number,
|
||||||
|
): MonsterDefinition[] {
|
||||||
|
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
|
||||||
|
const picks: MonsterDefinition[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const roll = this.randomSource.next() * totalWeight;
|
||||||
|
let cumulative = 0;
|
||||||
|
let picked: LocationMonster = pool[pool.length - 1];
|
||||||
|
for (const entry of pool) {
|
||||||
|
cumulative += entry.weight;
|
||||||
|
if (roll < cumulative) {
|
||||||
|
picked = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picks.push(picked.monster);
|
||||||
|
}
|
||||||
|
return picks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockCharacter(
|
||||||
|
characters: Repository<Character>,
|
||||||
|
characterId: string,
|
||||||
|
): Promise<Character> {
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
// The pre-transaction load above already confirmed the character
|
||||||
|
// exists; a miss here would only occur under a pathological
|
||||||
|
// concurrent deletion, which the schema's RESTRICT FKs prevent.
|
||||||
|
throw characterNotFound();
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLocationSummary(
|
||||||
|
location: Character['currentLocation'],
|
||||||
|
): LocationSummary {
|
||||||
|
return {
|
||||||
|
id: location.id,
|
||||||
|
key: location.key,
|
||||||
|
name: location.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/inventory/inventory.controller.ts
Normal file
13
apps/api/src/inventory/inventory.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { InventoryResponseDto, InventoryService } from './inventory.service';
|
||||||
|
|
||||||
|
@Controller('inventory')
|
||||||
|
export class InventoryController {
|
||||||
|
constructor(private readonly inventoryService: InventoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getInventory(): Promise<InventoryResponseDto> {
|
||||||
|
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/inventory/inventory.module.ts
Normal file
13
apps/api/src/inventory/inventory.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { InventoryController } from './inventory.controller';
|
||||||
|
import { InventoryService } from './inventory.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
|
||||||
|
controllers: [InventoryController],
|
||||||
|
providers: [InventoryService],
|
||||||
|
})
|
||||||
|
export class InventoryModule {}
|
||||||
99
apps/api/src/inventory/inventory.service.spec.ts
Normal file
99
apps/api/src/inventory/inventory.service.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { ItemType } from '../items/item-type.enum';
|
||||||
|
import { InventoryService } from './inventory.service';
|
||||||
|
|
||||||
|
const CHARACTER_ID = 'character-1';
|
||||||
|
|
||||||
|
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
|
||||||
|
return {
|
||||||
|
id: 'item-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: 'def-1',
|
||||||
|
quantity: 1,
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
itemDefinition: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
type: ItemType.WEAPON,
|
||||||
|
equipmentSlot: EquipmentSlot.WEAPON,
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as CharacterItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryService', () => {
|
||||||
|
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => {
|
||||||
|
const items = [
|
||||||
|
characterItem({ id: 'item-1', quantity: 1 }),
|
||||||
|
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
|
||||||
|
];
|
||||||
|
const characterItems = {
|
||||||
|
find: jest.fn().mockResolvedValue(items),
|
||||||
|
} as unknown as Repository<CharacterItem>;
|
||||||
|
const equipment = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
|
||||||
|
]),
|
||||||
|
} as unknown as Repository<CharacterEquipment>;
|
||||||
|
const service = new InventoryService(characterItems, equipment);
|
||||||
|
|
||||||
|
const result = await service.getInventory(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(characterItems.find).toHaveBeenCalledWith({
|
||||||
|
where: { characterId: CHARACTER_ID },
|
||||||
|
relations: { itemDefinition: true },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item-2',
|
||||||
|
quantity: 3,
|
||||||
|
equipped: false,
|
||||||
|
item: expect.objectContaining({ key: 'worn-short-sword' }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when the character owns nothing', async () => {
|
||||||
|
const characterItems = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as Repository<CharacterItem>;
|
||||||
|
const equipment = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as Repository<CharacterEquipment>;
|
||||||
|
const service = new InventoryService(characterItems, equipment);
|
||||||
|
|
||||||
|
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/api/src/inventory/inventory.service.ts
Normal file
73
apps/api/src/inventory/inventory.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
|
||||||
|
export interface InventoryItemDto {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
equipped: boolean;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
equipmentSlot: EquipmentSlot | null;
|
||||||
|
requiredLevel: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
bonusAttack: number;
|
||||||
|
bonusHp: number;
|
||||||
|
bonusArmor: number;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryResponseDto {
|
||||||
|
items: InventoryItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CharacterItem)
|
||||||
|
private readonly characterItems: Repository<CharacterItem>,
|
||||||
|
@InjectRepository(CharacterEquipment)
|
||||||
|
private readonly equipment: Repository<CharacterEquipment>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getInventory(characterId: string): Promise<InventoryResponseDto> {
|
||||||
|
const [items, equipped] = await Promise.all([
|
||||||
|
this.characterItems.find({
|
||||||
|
where: { characterId },
|
||||||
|
relations: { itemDefinition: true },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.equipment.find({ where: { characterId } }),
|
||||||
|
]);
|
||||||
|
const equippedIds = new Set(equipped.map((row) => row.characterItemId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((characterItem) => ({
|
||||||
|
id: characterItem.id,
|
||||||
|
quantity: characterItem.quantity,
|
||||||
|
equipped: equippedIds.has(characterItem.id),
|
||||||
|
item: {
|
||||||
|
key: characterItem.itemDefinition.key,
|
||||||
|
name: characterItem.itemDefinition.name,
|
||||||
|
description: characterItem.itemDefinition.description,
|
||||||
|
rarity: characterItem.itemDefinition.rarity,
|
||||||
|
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
|
||||||
|
requiredLevel: characterItem.itemDefinition.requiredLevel,
|
||||||
|
weaponDamage: characterItem.itemDefinition.weaponDamage,
|
||||||
|
bonusAttack: characterItem.itemDefinition.bonusAttack,
|
||||||
|
bonusHp: characterItem.itemDefinition.bonusHp,
|
||||||
|
bonusArmor: characterItem.itemDefinition.bonusArmor,
|
||||||
|
iconPath: characterItem.itemDefinition.iconPath,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/items/entities/character-item.entity.ts
Normal file
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
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
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
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
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
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
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
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
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
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/api/src/monsters/entities/encounter-type.enum.ts
Normal file
6
apps/api/src/monsters/entities/encounter-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum EncounterType {
|
||||||
|
NORMAL = 'NORMAL',
|
||||||
|
RARE = 'RARE',
|
||||||
|
ELITE = 'ELITE',
|
||||||
|
BOSS = 'BOSS',
|
||||||
|
}
|
||||||
45
apps/api/src/monsters/entities/location-monster.entity.ts
Normal file
45
apps/api/src/monsters/entities/location-monster.entity.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
import { EncounterType } from './encounter-type.enum';
|
||||||
|
import { MonsterDefinition } from './monster-definition.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'location_monsters' })
|
||||||
|
export class LocationMonster {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'location_id', type: 'uuid' })
|
||||||
|
locationId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'monster_id', type: 'uuid' })
|
||||||
|
monsterId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'weight', type: 'integer' })
|
||||||
|
weight!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'encounter_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: EncounterType,
|
||||||
|
enumName: 'location_monster_encounter_type_enum',
|
||||||
|
default: EncounterType.NORMAL,
|
||||||
|
})
|
||||||
|
encounterType!: EncounterType;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location!: LocationDefinition;
|
||||||
|
|
||||||
|
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'monster_id' })
|
||||||
|
monster!: MonsterDefinition;
|
||||||
|
}
|
||||||
67
apps/api/src/monsters/entities/monster-definition.entity.ts
Normal file
67
apps/api/src/monsters/entities/monster-definition.entity.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
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 })
|
||||||
|
export class MonsterDefinition {
|
||||||
|
@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: 'level', type: 'integer' })
|
||||||
|
level!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_hp', type: 'integer' })
|
||||||
|
maxHp!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'attack', type: 'integer' })
|
||||||
|
attack!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'armor', type: 'integer' })
|
||||||
|
armor!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'experience_reward', type: 'integer' })
|
||||||
|
experienceReward!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'silver_min', type: 'integer' })
|
||||||
|
silverMin!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'silver_max', type: 'integer' })
|
||||||
|
silverMax!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
|
artworkPath!: string;
|
||||||
|
|
||||||
|
// Round medallion portrait used wherever a monster appears at icon size
|
||||||
|
// (encounter preview in the local location view). `artworkPath` stays the
|
||||||
|
// wide combat/hunt portrait.
|
||||||
|
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||||
|
iconPath!: 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
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
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
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
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
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
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 {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user