Compare commits
202 Commits
85184e6e53
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b738a072d | ||
|
|
69a26460ae | ||
|
|
fb7a96f032 | ||
|
|
93916457cb | ||
|
|
2dadc5c4a7 | ||
|
|
139c5517f2 | ||
|
|
481c6be5a8 | ||
|
|
1a7018d790 | ||
|
|
a454cf955e | ||
|
|
f0edf89ad3 | ||
|
|
ab8bbeae5d | ||
|
|
81e44b2c15 | ||
|
|
7b03310458 | ||
|
|
374926e494 | ||
|
|
681af334bf | ||
|
|
00784094ab | ||
|
|
36adc32659 | ||
|
|
80be829309 | ||
|
|
7a5e800a18 | ||
|
|
7e1b79315e | ||
|
|
149a9521be | ||
|
|
9bd28b5e2c | ||
|
|
c295bae63a | ||
|
|
3b28450fb1 | ||
|
|
8e6ec7b43c | ||
|
|
081c9f83f9 | ||
|
|
dfa62fd152 | ||
|
|
8418a93329 | ||
|
|
6b32f14bb5 | ||
|
|
fdd83f2dee | ||
|
|
1322285b0f | ||
|
|
ae163f000a | ||
|
|
846bb7428c | ||
|
|
14d26d06a4 | ||
|
|
1c0a13475c | ||
|
|
ca3e072807 | ||
|
|
833074d9fe | ||
|
|
2dd0f32f88 | ||
|
|
0923fc0a23 | ||
|
|
5586e6c672 | ||
|
|
5222bfb2ab | ||
|
|
e48409fbf1 | ||
|
|
b8ee56ca1a | ||
|
|
74fc89dfe5 | ||
|
|
9214bf99d2 | ||
|
|
cedd87f648 | ||
|
|
23ed527eea | ||
|
|
0c9f00d9a8 | ||
|
|
688c4cf70f | ||
|
|
18f4be586b | ||
|
|
b350c3f9b1 | ||
|
|
b5bcd50dcd | ||
|
|
4cb759b628 | ||
|
|
221819880c | ||
|
|
ab6227881e | ||
|
|
848f9d9195 | ||
|
|
c27b6c5026 | ||
|
|
caf63b34a4 | ||
|
|
cfe1866956 | ||
|
|
52178ba79d | ||
|
|
dd204db4b3 | ||
|
|
9d1c1a7706 | ||
|
|
b070bf2b0d | ||
|
|
c0401d5c60 | ||
|
|
df4c0c5527 | ||
|
|
ac1329be46 | ||
|
|
cea1d2ecf5 | ||
|
|
432483e958 | ||
|
|
909f793eb6 | ||
|
|
02037b2917 | ||
|
|
c32d1804d3 | ||
|
|
47494abf50 | ||
|
|
71f58bc1b6 | ||
|
|
7609c49899 | ||
|
|
e5752c7ee9 | ||
|
|
ea26e4b844 | ||
|
|
40ff950390 | ||
|
|
19e08f0b16 | ||
|
|
b55518b451 | ||
|
|
34a7bb9dbd | ||
|
|
2579b23a5c | ||
|
|
8835671657 | ||
|
|
a75a670509 | ||
|
|
dce26e00ad | ||
|
|
dd90e05446 | ||
|
|
413fafd574 | ||
|
|
83eef5d9e5 | ||
|
|
c9d9144b5f | ||
|
|
6fb61792e7 | ||
|
|
4e3af9fe39 | ||
|
|
987242541d | ||
|
|
321738496d | ||
|
|
79d4e04172 | ||
|
|
4c9397336f | ||
|
|
152499acfc | ||
|
|
4fa1923525 | ||
|
|
3662029ec9 | ||
|
|
1a50e817f1 | ||
|
|
88b98aa605 | ||
|
|
e14f6dcbce | ||
|
|
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 | ||
|
|
498349b5eb | ||
|
|
90f6a8cd78 | ||
|
|
bd8a00227f | ||
|
|
edae1af39a | ||
|
|
6cb4d02613 | ||
|
|
47931ff717 | ||
|
|
68edc04ed6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ coverage/
|
||||
.vscode/
|
||||
|
||||
.DS_Store
|
||||
|
||||
.worktrees/
|
||||
12
README.md
12
README.md
@@ -7,8 +7,8 @@ A dark-fantasy browser RPG built as an npm-workspace modular monolith:
|
||||
- `packages/*` — reserved shared boundaries (currently unused)
|
||||
|
||||
This README documents the first visible vertical slice: a server-authoritative
|
||||
world/travel loop between two locations (Südtor von Graufurt and Verbrannte
|
||||
Straße) for one demo character, with no login required.
|
||||
world/travel loop between two locations (Graufurt South Gate and Burned Road)
|
||||
for one demo character, with no login required.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -30,6 +30,10 @@ Straße) for one demo character, with no login required.
|
||||
## Local setup
|
||||
|
||||
Run from the repository root:
|
||||
```
|
||||
docker run --name ashen-postgres -e POSTGRES_USER=ashen -e POSTGRES_PASSWORD=ashen -e POSTGRES_DB=ashen_realms -p 5432:5432 -d postgres:16
|
||||
|
||||
```
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
@@ -47,8 +51,8 @@ running. Once both are up:
|
||||
- Web: `http://localhost:4200`, which proxies `/api/*` requests to
|
||||
`http://localhost:3000` in development (see `apps/web/proxy.conf.json`)
|
||||
|
||||
Open `http://localhost:4200/world` to see Aric Duskwalker at the Südtor von
|
||||
Graufurt and travel to the Verbrannte Straße and back.
|
||||
Open `http://localhost:4200/world` to see Aric Duskwalker at the Graufurt
|
||||
South Gate and travel to the Burned Road and back.
|
||||
|
||||
### Configuration (`.env`)
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CharactersModule } from './characters/characters.module';
|
||||
import { CombatModule } from './combat/combat.module';
|
||||
import { ConditionsModule } from './conditions/conditions.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { EquipmentModule } from './equipment/equipment.module';
|
||||
import { ExchangesModule } from './exchanges/exchanges.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { HuntingModule } from './hunting/hunting.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { LootBagsModule } from './loot-bags/loot-bags.module';
|
||||
import { NpcsModule } from './npcs/npcs.module';
|
||||
import { RenownModule } from './renown/renown.module';
|
||||
import { ReputationModule } from './reputation/reputation.module';
|
||||
import { ShopsModule } from './shops/shops.module';
|
||||
import { TravelModule } from './travel/travel.module';
|
||||
import { WorldModule } from './world/world.module';
|
||||
|
||||
@@ -14,6 +24,16 @@ import { WorldModule } from './world/world.module';
|
||||
TravelModule,
|
||||
WorldModule,
|
||||
HuntingModule,
|
||||
CombatModule,
|
||||
EquipmentModule,
|
||||
InventoryModule,
|
||||
LootBagsModule,
|
||||
RenownModule,
|
||||
ReputationModule,
|
||||
ConditionsModule,
|
||||
NpcsModule,
|
||||
ShopsModule,
|
||||
ExchangesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
170
apps/api/src/characters/character-stats.service.spec.ts
Normal file
170
apps/api/src/characters/character-stats.service.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
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 the Bandit Blade'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;
|
||||
139
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
139
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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 { 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 { CharactersService } from './characters.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
@@ -7,6 +10,12 @@ import { Character } from './entities/character.entity';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Character])],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService],
|
||||
providers: [
|
||||
CharactersService,
|
||||
CharacterStatsService,
|
||||
CharacterVitalsService,
|
||||
{ provide: CLOCK, useValue: systemClock },
|
||||
],
|
||||
exports: [CharacterStatsService, CharacterVitalsService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
|
||||
@@ -2,54 +2,98 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
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', () => {
|
||||
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 = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
renown: 1,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'S\u00fcdtor von Graufurt',
|
||||
name: 'Graufurt South Gate',
|
||||
},
|
||||
}),
|
||||
} 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({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
renown: 1,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
maxHp: 115,
|
||||
attack: 7,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: '2026-08-18T09:00:00.000Z',
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'S\u00fcdtor von Graufurt',
|
||||
name: 'Graufurt South Gate',
|
||||
},
|
||||
});
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: DEMO_CHARACTER_ID },
|
||||
relations: { currentLocation: true },
|
||||
expect(characterStats.calculate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
renown: 5,
|
||||
silver: 18,
|
||||
currentHp: 100,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'Graufurt South Gate',
|
||||
},
|
||||
}),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository, fakeCharacterStats());
|
||||
|
||||
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||
expect.objectContaining({ renown: 5, silver: 18 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a missing demo seed as not found', async () => {
|
||||
const repository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Repository<Character>;
|
||||
const service = new CharactersService(repository);
|
||||
const service = new CharactersService(repository, fakeCharacterStats());
|
||||
|
||||
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -9,6 +10,7 @@ export class CharactersService {
|
||||
constructor(
|
||||
@InjectRepository(Character)
|
||||
private readonly characters: Repository<Character>,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
) {}
|
||||
|
||||
async getDemoCharacter() {
|
||||
@@ -21,14 +23,20 @@ export class CharactersService {
|
||||
throw new NotFoundException('Demo character has not been seeded');
|
||||
}
|
||||
|
||||
const stats = await this.characterStats.calculate(character);
|
||||
|
||||
return {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
level: character.level,
|
||||
experience: character.experience,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: character.baseHp,
|
||||
attack: character.baseAttack,
|
||||
renown: character.renown,
|
||||
silver: character.silver,
|
||||
currentHp: stats.currentHp,
|
||||
maxHp: stats.maxHp,
|
||||
attack: stats.attack,
|
||||
hpRegenPerSecond: stats.hpRegenPerSecond,
|
||||
hpRegenSince: stats.hpRegenSince
|
||||
? stats.hpRegenSince.toISOString()
|
||||
: null,
|
||||
currentLocation: {
|
||||
id: character.currentLocation.id,
|
||||
key: character.currentLocation.key,
|
||||
|
||||
@@ -17,11 +17,11 @@ export class Character {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'level', type: 'integer' })
|
||||
level!: number;
|
||||
@Column({ name: 'renown', type: 'integer' })
|
||||
renown!: number;
|
||||
|
||||
@Column({ name: 'experience', type: 'integer' })
|
||||
experience!: number;
|
||||
@Column({ name: 'silver', type: 'integer' })
|
||||
silver!: number;
|
||||
|
||||
@Column({ name: 'base_hp', type: 'integer' })
|
||||
baseHp!: number;
|
||||
@@ -32,6 +32,12 @@ export class Character {
|
||||
@Column({ name: 'current_hp', type: 'integer' })
|
||||
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' })
|
||||
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',
|
||||
}
|
||||
28
apps/api/src/combat/combat-damage.spec.ts
Normal file
28
apps/api/src/combat/combat-damage.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
18
apps/api/src/combat/combat-damage.ts
Normal file
18
apps/api/src/combat/combat-damage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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));
|
||||
}
|
||||
728
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
728
apps/api/src/combat/combat-engine.service.spec.ts
Normal file
@@ -0,0 +1,728 @@
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import {
|
||||
CombatEngineService,
|
||||
UnsupportedCombatActionError,
|
||||
} from './combat-engine.service';
|
||||
import {
|
||||
CombatEngineCombatant,
|
||||
CombatEngineState,
|
||||
} from './combat-engine.types';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { Combatant } from './combatant.enum';
|
||||
import { StatusEffectType } from './status-effect.enum';
|
||||
import type { MonsterAbilities } from '../monsters/monster-abilities';
|
||||
|
||||
function baseState(
|
||||
overrides: Partial<CombatEngineState> = {},
|
||||
): CombatEngineState {
|
||||
return {
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
player: {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
stats: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||
},
|
||||
// The Ash Rat baseline: no telegraph, no status effect (spec §4).
|
||||
monster: monster(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function monster(
|
||||
overrides: Partial<CombatEngineCombatant> = {},
|
||||
): CombatEngineCombatant {
|
||||
return {
|
||||
currentHp: 45,
|
||||
maxHp: 45,
|
||||
stats: { attack: 5, armor: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A monster whose content grants it an ability. Every special behaviour is
|
||||
* configuration now (spec §4, §8), so a test that wants a telegraph or a
|
||||
* bleed has to say so -- there is no rule every enemy shares any more.
|
||||
*/
|
||||
function withAbilities(
|
||||
abilities: MonsterAbilities,
|
||||
overrides: Partial<CombatEngineCombatant> = {},
|
||||
): CombatEngineCombatant {
|
||||
const base = monster(overrides);
|
||||
return { ...base, stats: { ...base.stats, abilities } };
|
||||
}
|
||||
|
||||
const ROAD_BANDIT_ABILITIES: MonsterAbilities = {
|
||||
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
||||
};
|
||||
|
||||
const ROAD_HOUND_ABILITIES: MonsterAbilities = {
|
||||
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
||||
};
|
||||
|
||||
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: withAbilities(ROAD_BANDIT_ABILITIES, {
|
||||
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,
|
||||
monster: withAbilities(ROAD_BANDIT_ABILITIES),
|
||||
});
|
||||
|
||||
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: withAbilities(ROAD_BANDIT_ABILITIES, {
|
||||
currentHp: 10,
|
||||
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: withAbilities(ROAD_BANDIT_ABILITIES, {
|
||||
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);
|
||||
});
|
||||
|
||||
describe('content-driven monster abilities (spec §4, §8)', () => {
|
||||
it('never telegraphs for a monster whose content grants it no telegraph', () => {
|
||||
// The Ash Rat is "pure baseline combat": round 3 must be an ordinary
|
||||
// exchange for it, even though it is the Road Bandit's wind-up round.
|
||||
const result = engine.resolveAction(baseState({ round: 3 }), {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.events.some((event) => event.type === CombatEventType.TELEGRAPH),
|
||||
).toBe(false);
|
||||
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||
expect(result.state.player.currentHp).toBe(100 - 5);
|
||||
});
|
||||
|
||||
it('telegraphs on the cadence its own content sets, not a shared one', () => {
|
||||
// The Charred Raider winds up every second round rather than every
|
||||
// third, which is the whole of its "noticeably stronger" mechanic.
|
||||
const raider = withAbilities({
|
||||
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
||||
});
|
||||
|
||||
const round2 = engine.resolveAction(
|
||||
baseState({ round: 2, monster: raider }),
|
||||
{ action: CombatAction.ATTACK },
|
||||
);
|
||||
const round3 = engine.resolveAction(
|
||||
baseState({ round: 3, monster: raider }),
|
||||
{ action: CombatAction.ATTACK },
|
||||
);
|
||||
|
||||
expect(round2.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
||||
expect(round3.state.monster.stats.pendingAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the damage multiplier from the monster content when the Heavy Attack lands', () => {
|
||||
const state = baseState({
|
||||
monster: withAbilities(
|
||||
{ telegraph: { roundInterval: 3, damageMultiplier: 3 } },
|
||||
{ stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' } },
|
||||
),
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
// raw = 5; mitigated = 4.545...; * 3 = 13.6 -> rounds to 14
|
||||
expect(result.events[1]).toMatchObject({
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: 14,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bleeding (spec §4)', () => {
|
||||
it('applies Bleeding on top of a normal bite, then ticks it the same round', () => {
|
||||
const state = baseState({
|
||||
round: 3,
|
||||
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(result.events.slice(1)).toEqual([
|
||||
{
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: 5,
|
||||
},
|
||||
{
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.STATUS_APPLIED,
|
||||
amount: 2,
|
||||
statusEffect: StatusEffectType.BLEED,
|
||||
},
|
||||
{
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.STATUS_DAMAGE,
|
||||
amount: 5,
|
||||
statusEffect: StatusEffectType.BLEED,
|
||||
},
|
||||
]);
|
||||
// The bite (5) and the first bleed tick (5) both land this round.
|
||||
expect(result.state.player.currentHp).toBe(100 - 10);
|
||||
expect(result.state.player.stats.statusEffects).toEqual([
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 1,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps ticking on later rounds and expires once its duration runs out', () => {
|
||||
const state = baseState({
|
||||
round: 4,
|
||||
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
||||
player: {
|
||||
currentHp: 90,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 1,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(result.events.map((event) => event.type)).toEqual([
|
||||
CombatEventType.DAMAGE,
|
||||
CombatEventType.DAMAGE,
|
||||
CombatEventType.STATUS_DAMAGE,
|
||||
CombatEventType.STATUS_EXPIRED,
|
||||
]);
|
||||
// Monster bite (5) plus the last bleed tick (5).
|
||||
expect(result.state.player.currentHp).toBe(90 - 10);
|
||||
expect(result.state.player.stats.statusEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores armor, because a bleed is an open wound and not a blow', () => {
|
||||
const armoured = baseState({
|
||||
round: 4,
|
||||
player: {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 999,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 2,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(armoured, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.events.find(
|
||||
(event) => event.type === CombatEventType.STATUS_DAMAGE,
|
||||
),
|
||||
).toMatchObject({ amount: 5 });
|
||||
});
|
||||
|
||||
it('still ticks in a round where SHIELD_BASH interrupted the monster', () => {
|
||||
// Bleeding sits on the player: interrupting its source stops the next
|
||||
// blow, not the wound already open.
|
||||
const state = baseState({
|
||||
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
|
||||
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||
}),
|
||||
player: {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 2,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.SHIELD_BASH,
|
||||
});
|
||||
|
||||
expect(result.events.map((event) => event.type)).toEqual([
|
||||
CombatEventType.DAMAGE,
|
||||
CombatEventType.INTERRUPT,
|
||||
CombatEventType.STATUS_DAMAGE,
|
||||
]);
|
||||
expect(result.state.player.currentHp).toBe(95);
|
||||
});
|
||||
|
||||
it('refreshes an existing Bleeding rather than stacking a second one', () => {
|
||||
const state = baseState({
|
||||
round: 3,
|
||||
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
||||
player: {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 1,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(result.state.player.stats.statusEffects).toHaveLength(1);
|
||||
// Re-applied to 2 rounds, then aged by this round's own tick.
|
||||
expect(result.state.player.stats.statusEffects?.[0].remainingRounds).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the combat as LOST when a bleed tick empties the player', () => {
|
||||
const state = baseState({
|
||||
round: 4,
|
||||
player: {
|
||||
currentHp: 4,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 999,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 2,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.DEFEND,
|
||||
});
|
||||
|
||||
expect(result.state.player.currentHp).toBe(0);
|
||||
expect(result.state.status).toBe(CombatStatus.LOST);
|
||||
expect(result.events.at(-1)).toEqual({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.COMBAT_LOST,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not tick a bleed once the killing blow has already won the fight', () => {
|
||||
const state = baseState({
|
||||
monster: monster({ currentHp: 10 }),
|
||||
player: {
|
||||
currentHp: 3,
|
||||
maxHp: 100,
|
||||
stats: {
|
||||
attack: 6,
|
||||
weaponDamage: 8,
|
||||
armor: 6,
|
||||
statusEffects: [
|
||||
{
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: 2,
|
||||
damagePerRound: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = engine.resolveAction(state, {
|
||||
action: CombatAction.ATTACK,
|
||||
});
|
||||
|
||||
expect(result.state.status).toBe(CombatStatus.WON);
|
||||
expect(result.state.player.currentHp).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mutate the state it was given', () => {
|
||||
const state = baseState({
|
||||
round: 3,
|
||||
monster: withAbilities(ROAD_HOUND_ABILITIES),
|
||||
});
|
||||
const snapshot = structuredClone(state);
|
||||
|
||||
engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||
|
||||
expect(state).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
});
|
||||
382
apps/api/src/combat/combat-engine.service.ts
Normal file
382
apps/api/src/combat/combat-engine.service.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { calculateDamage } from './combat-damage';
|
||||
import { Combatant } from './combatant.enum';
|
||||
import {
|
||||
ActiveStatusEffect,
|
||||
CombatActionInput,
|
||||
CombatEngineCombatant,
|
||||
CombatEngineEvent,
|
||||
CombatEngineResult,
|
||||
CombatEngineState,
|
||||
} from './combat-engine.types';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { StatusEffectType } from './status-effect.enum';
|
||||
import type {
|
||||
MonsterBleedAbility,
|
||||
MonsterTelegraphAbility,
|
||||
} from '../monsters/monster-abilities';
|
||||
|
||||
export class UnsupportedCombatActionError extends Error {
|
||||
constructor(action: string) {
|
||||
super(`Unsupported combat action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Player-side action modifiers. These are character abilities rather than
|
||||
// monster content, so they stay constants here (Playable Slice 0.6 spec
|
||||
// §10/§11). What the *monster* does now comes from its own definition
|
||||
// (Playable Slice 0.7 V2 spec §4, §8) instead of a rule every enemy shares.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the round: monster reply, then ongoing effects, then outcome.
|
||||
*
|
||||
* Status effects tick after the monster has acted and regardless of whether
|
||||
* it acted at all -- Bleeding sits on the player and does not care that
|
||||
* SHIELD_BASH interrupted the enemy that caused it. An effect applied this
|
||||
* round therefore also deals its first tick this round.
|
||||
*/
|
||||
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) {
|
||||
this.tickStatusEffects(player, 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;
|
||||
const abilities = monster.stats.abilities ?? {};
|
||||
|
||||
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
|
||||
monster.stats.pendingAction = undefined;
|
||||
this.strikePlayer(
|
||||
monster,
|
||||
player,
|
||||
(abilities.telegraph?.damageMultiplier ?? HEAVY_ATTACK_MULTIPLIER) *
|
||||
defendMultiplier,
|
||||
events,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldTrigger(abilities.telegraph, round)) {
|
||||
monster.stats.pendingAction = 'HEAVY_ATTACK';
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.TELEGRAPH,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.strikePlayer(monster, player, defendMultiplier, events);
|
||||
|
||||
// The bite lands first and then tears: Bleeding is applied on top of a
|
||||
// normal attack, not instead of one (spec §4).
|
||||
const bleed = abilities.bleed;
|
||||
if (bleed && this.shouldTrigger(bleed, round)) {
|
||||
this.applyBleed(player, bleed, events);
|
||||
}
|
||||
}
|
||||
|
||||
private strikePlayer(
|
||||
monster: CombatEngineCombatant,
|
||||
player: CombatEngineCombatant,
|
||||
multiplier: number,
|
||||
events: CombatEngineEvent[],
|
||||
): void {
|
||||
const damage = calculateDamage(
|
||||
monster.stats,
|
||||
player.stats.armor,
|
||||
multiplier,
|
||||
);
|
||||
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.DAMAGE,
|
||||
amount: damage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-configured ability fires on every round divisible by its
|
||||
* interval. A fixed cadence rather than a hidden roll keeps the fight
|
||||
* readable and the engine deterministic (AGENTS §10).
|
||||
*/
|
||||
private shouldTrigger(
|
||||
ability: MonsterTelegraphAbility | MonsterBleedAbility | undefined,
|
||||
round: number,
|
||||
): boolean {
|
||||
return (
|
||||
ability !== undefined &&
|
||||
ability.roundInterval > 0 &&
|
||||
round % ability.roundInterval === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-applying refreshes the duration rather than stacking a second wound. */
|
||||
private applyBleed(
|
||||
player: CombatEngineCombatant,
|
||||
bleed: MonsterBleedAbility,
|
||||
events: CombatEngineEvent[],
|
||||
): void {
|
||||
const effects = player.stats.statusEffects ?? [];
|
||||
const existing = effects.find(
|
||||
(effect) => effect.type === StatusEffectType.BLEED,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.remainingRounds = bleed.durationRounds;
|
||||
existing.damagePerRound = bleed.damagePerRound;
|
||||
} else {
|
||||
effects.push({
|
||||
type: StatusEffectType.BLEED,
|
||||
remainingRounds: bleed.durationRounds,
|
||||
damagePerRound: bleed.damagePerRound,
|
||||
});
|
||||
}
|
||||
player.stats.statusEffects = effects;
|
||||
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.STATUS_APPLIED,
|
||||
amount: bleed.durationRounds,
|
||||
statusEffect: StatusEffectType.BLEED,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deals one round of every active effect, then ages it. Bleeding ignores
|
||||
* armor: it is an open wound, not a blow that can be turned aside.
|
||||
*/
|
||||
private tickStatusEffects(
|
||||
combatant: CombatEngineCombatant,
|
||||
events: CombatEngineEvent[],
|
||||
): void {
|
||||
const effects = combatant.stats.statusEffects ?? [];
|
||||
if (effects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surviving: ActiveStatusEffect[] = [];
|
||||
for (const effect of effects) {
|
||||
const damage = Math.max(0, effect.damagePerRound);
|
||||
combatant.currentHp = Math.max(0, combatant.currentHp - damage);
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.STATUS_DAMAGE,
|
||||
amount: damage,
|
||||
statusEffect: effect.type,
|
||||
});
|
||||
|
||||
const remainingRounds = effect.remainingRounds - 1;
|
||||
if (remainingRounds > 0) {
|
||||
surviving.push({ ...effect, remainingRounds });
|
||||
} else {
|
||||
events.push({
|
||||
source: Combatant.MONSTER,
|
||||
target: Combatant.PLAYER,
|
||||
type: CombatEventType.STATUS_EXPIRED,
|
||||
statusEffect: effect.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
combatant.stats.statusEffects = surviving;
|
||||
}
|
||||
|
||||
private cloneCombatant(
|
||||
combatant: CombatEngineCombatant,
|
||||
): CombatEngineCombatant {
|
||||
return {
|
||||
...combatant,
|
||||
stats: {
|
||||
...combatant.stats,
|
||||
statusEffects: combatant.stats.statusEffects?.map((effect) => ({
|
||||
...effect,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
73
apps/api/src/combat/combat-engine.types.ts
Normal file
73
apps/api/src/combat/combat-engine.types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Combatant } from './combatant.enum';
|
||||
import { CombatAction } from './combat-action.enum';
|
||||
import { CombatEventType } from './combat-event-type.enum';
|
||||
import { CombatStatus } from './combat-status.enum';
|
||||
import { StatusEffectType } from './status-effect.enum';
|
||||
import type { MonsterAbilities } from '../monsters/monster-abilities';
|
||||
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* An ongoing effect carried between rounds (Playable Slice 0.7 V2 spec §4).
|
||||
*
|
||||
* Duration and damage are copied from the applying monster's content at the
|
||||
* moment it lands, so the effect keeps ticking on its own terms even if the
|
||||
* definition is retuned mid-fight. Server-authoritative: the client only
|
||||
* ever renders what this says.
|
||||
*/
|
||||
export interface ActiveStatusEffect {
|
||||
type: StatusEffectType;
|
||||
remainingRounds: number;
|
||||
damagePerRound: number;
|
||||
}
|
||||
|
||||
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;
|
||||
// Monster-only: the content-authored mechanics this enemy fights with.
|
||||
// Absent (or empty) means a plain attacker with no special behaviour.
|
||||
abilities?: MonsterAbilities;
|
||||
// Effects currently ticking on this combatant. Only the player carries
|
||||
// any today -- nothing in this slice bleeds a monster.
|
||||
statusEffects?: ActiveStatusEffect[];
|
||||
}
|
||||
|
||||
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;
|
||||
statusEffect?: StatusEffectType;
|
||||
}
|
||||
|
||||
export interface CombatEngineResult {
|
||||
state: CombatEngineState;
|
||||
events: CombatEngineEvent[];
|
||||
}
|
||||
443
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
443
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
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: 'Road Bandit',
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
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: 'Worn Shortsword',
|
||||
description: '',
|
||||
type: ItemType.EQUIPMENT,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
rarity: ItemRarity.COMMON,
|
||||
tier: 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({ 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: 'Bandit Blade',
|
||||
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 the Bandit Blade 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);
|
||||
});
|
||||
});
|
||||
12
apps/api/src/combat/combat-event-type.enum.ts
Normal file
12
apps/api/src/combat/combat-event-type.enum.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum CombatEventType {
|
||||
DAMAGE = 'DAMAGE',
|
||||
HEAL = 'HEAL',
|
||||
DEFEND = 'DEFEND',
|
||||
TELEGRAPH = 'TELEGRAPH',
|
||||
INTERRUPT = 'INTERRUPT',
|
||||
STATUS_APPLIED = 'STATUS_APPLIED',
|
||||
STATUS_DAMAGE = 'STATUS_DAMAGE',
|
||||
STATUS_EXPIRED = 'STATUS_EXPIRED',
|
||||
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',
|
||||
}
|
||||
140
apps/api/src/combat/combat.controller.spec.ts
Normal file
140
apps/api/src/combat/combat.controller.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
32
apps/api/src/combat/combat.controller.ts
Normal file
32
apps/api/src/combat/combat.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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';
|
||||
34
apps/api/src/combat/combat.module.ts
Normal file
34
apps/api/src/combat/combat.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 {}
|
||||
1161
apps/api/src/combat/combat.service.spec.ts
Normal file
1161
apps/api/src/combat/combat.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
485
apps/api/src/combat/combat.service.ts
Normal file
485
apps/api/src/combat/combat.service.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
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 {
|
||||
ActiveStatusEffect,
|
||||
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,
|
||||
CombatMonsterState,
|
||||
CombatPlayerState,
|
||||
} from './entities/combat.entity';
|
||||
import { StatusEffectType } from './status-effect.enum';
|
||||
|
||||
// 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 CombatStatusEffectDto {
|
||||
type: StatusEffectType;
|
||||
remainingRounds: number;
|
||||
damagePerRound: number;
|
||||
}
|
||||
|
||||
export interface CombatPlayerDto {
|
||||
name: string;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
potionsRemaining: number;
|
||||
potionsMax: number;
|
||||
statusEffects: CombatStatusEffectDto[];
|
||||
}
|
||||
|
||||
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;
|
||||
statusEffect?: StatusEffectType;
|
||||
}
|
||||
|
||||
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,
|
||||
abilities: monster.abilities ?? {},
|
||||
},
|
||||
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). Keeping both code
|
||||
// paths on the same order 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 as CombatMonsterState;
|
||||
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,
|
||||
statusEffect: event.statusEffect ?? 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,
|
||||
statusEffects: this.toStatusEffectDtos(
|
||||
combat.playerState.statusEffects,
|
||||
),
|
||||
},
|
||||
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,
|
||||
statusEffect: event.statusEffect ?? undefined,
|
||||
})),
|
||||
rewards,
|
||||
};
|
||||
}
|
||||
|
||||
private toStatusEffectDtos(
|
||||
effects: ActiveStatusEffect[] | undefined,
|
||||
): CombatStatusEffectDto[] {
|
||||
return (effects ?? []).map((effect) => ({
|
||||
type: effect.type,
|
||||
remainingRounds: effect.remainingRounds,
|
||||
damagePerRound: effect.damagePerRound,
|
||||
}));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
76
apps/api/src/combat/entities/combat-event.entity.ts
Normal file
76
apps/api/src/combat/entities/combat-event.entity.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Combatant } from '../combatant.enum';
|
||||
import { CombatEventType } from '../combat-event-type.enum';
|
||||
import { StatusEffectType } from '../status-effect.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;
|
||||
|
||||
// Which ongoing effect a STATUS_* event is about. Null for every other
|
||||
// event type (spec §4).
|
||||
@Column({
|
||||
name: 'status_effect',
|
||||
type: 'enum',
|
||||
enum: StatusEffectType,
|
||||
enumName: 'status_effect_type_enum',
|
||||
nullable: true,
|
||||
})
|
||||
statusEffect!: StatusEffectType | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'combat_id' })
|
||||
combat!: Combat;
|
||||
}
|
||||
102
apps/api/src/combat/entities/combat.entity.ts
Normal file
102
apps/api/src/combat/entities/combat.entity.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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 { ActiveStatusEffect, CombatIntent } from '../combat-engine.types';
|
||||
import { CombatStatus } from '../combat-status.enum';
|
||||
import type { MonsterAbilities } from '../../monsters/monster-abilities';
|
||||
|
||||
export interface CombatCombatantState {
|
||||
attack: number;
|
||||
armor: number;
|
||||
pendingAction?: CombatIntent;
|
||||
statusEffects?: ActiveStatusEffect[];
|
||||
}
|
||||
|
||||
export interface CombatMonsterState extends CombatCombatantState {
|
||||
// Snapshotted from MonsterDefinition when the fight starts, so retuning
|
||||
// content mid-fight cannot change the rules of a running combat.
|
||||
abilities: MonsterAbilities;
|
||||
}
|
||||
|
||||
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!: CombatMonsterState;
|
||||
|
||||
@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;
|
||||
}
|
||||
49
apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
Normal file
49
apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/combat/status-effect.enum.ts
Normal file
9
apps/api/src/combat/status-effect.enum.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Ongoing effects a combatant can carry between rounds (spec §4).
|
||||
*
|
||||
* Only the Feral Road Hound's Bleeding exists in this slice; the enum is the
|
||||
* extension point later status effects hang off.
|
||||
*/
|
||||
export enum StatusEffectType {
|
||||
BLEED = 'BLEED',
|
||||
}
|
||||
32
apps/api/src/conditions/conditions.module.ts
Normal file
32
apps/api/src/conditions/conditions.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
|
||||
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { GameConditionService } from './game-condition.service';
|
||||
|
||||
/**
|
||||
* The shared gate engine (NPC spec §21).
|
||||
*
|
||||
* `forFeature` is required even though the service resolves its repositories
|
||||
* off the DataSource: the runtime config uses `autoLoadEntities`, which only
|
||||
* registers entities a module actually declares.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Character,
|
||||
CharacterItem,
|
||||
ItemDefinition,
|
||||
CharacterNpcState,
|
||||
CharacterReputation,
|
||||
ReputationFaction,
|
||||
]),
|
||||
],
|
||||
providers: [GameConditionService],
|
||||
exports: [GameConditionService],
|
||||
})
|
||||
export class ConditionsModule {}
|
||||
317
apps/api/src/conditions/game-condition.service.spec.ts
Normal file
317
apps/api/src/conditions/game-condition.service.spec.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { DataSource } from '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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
|
||||
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { GameConditionService } from './game-condition.service';
|
||||
import { ComparisonOperator, GameConditionType } from './game-condition.types';
|
||||
|
||||
const CHARACTER_ID = 'character-1';
|
||||
const NPC_ID = 'npc-1';
|
||||
const FACTION_ID = 'faction-1';
|
||||
|
||||
interface Fixture {
|
||||
renown?: number;
|
||||
reputation?: number;
|
||||
factionEnabled?: boolean;
|
||||
flags?: Record<string, boolean | string | number> | null;
|
||||
itemQuantity?: number;
|
||||
}
|
||||
|
||||
function createService(fixture: Fixture = {}): GameConditionService {
|
||||
const dataSource = {
|
||||
getRepository: (entity: unknown) => {
|
||||
if (entity === Character) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve({
|
||||
id: CHARACTER_ID,
|
||||
renown: fixture.renown ?? 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === ReputationFaction) {
|
||||
return {
|
||||
findOneBy: (criteria: { key: string; enabled: boolean }) =>
|
||||
Promise.resolve(
|
||||
criteria.key === 'border-guard' &&
|
||||
(fixture.factionEnabled ?? true) === criteria.enabled
|
||||
? { id: FACTION_ID, key: 'border-guard' }
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === CharacterReputation) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve(
|
||||
fixture.reputation === undefined
|
||||
? null
|
||||
: { reputation: fixture.reputation },
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === CharacterNpcState) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve(
|
||||
fixture.flags === undefined || fixture.flags === null
|
||||
? null
|
||||
: { flags: fixture.flags },
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === ItemDefinition) {
|
||||
return {
|
||||
findOneBy: (criteria: { key: string }) =>
|
||||
Promise.resolve(
|
||||
criteria.key === 'ash-pelt' ? { id: 'item-1' } : null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === CharacterItem) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve(
|
||||
fixture.itemQuantity === undefined
|
||||
? null
|
||||
: { quantity: fixture.itemQuantity },
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected repository');
|
||||
},
|
||||
} as unknown as DataSource;
|
||||
|
||||
return new GameConditionService(dataSource);
|
||||
}
|
||||
|
||||
describe('GameConditionService', () => {
|
||||
it('treats an empty condition list as met, so ungated content stays open', async () => {
|
||||
await expect(
|
||||
createService().evaluate({ characterId: CHARACTER_ID }, []),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
createService().evaluate({ characterId: CHARACTER_ID }, null),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('compares regional reputation against the required value (spec §20)', async () => {
|
||||
const service = createService({ reputation: 30 });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 31,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reads a faction the character never met as 0, not as a pass', async () => {
|
||||
// No CharacterReputation row exists. The gate must still evaluate, and it
|
||||
// must evaluate against zero.
|
||||
const service = createService({ reputation: undefined });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 1,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('compares World Renown against the character rank', async () => {
|
||||
const service = createService({ renown: 3 });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('reads a per-NPC flag, and only when an NPC is in context (spec §7)', async () => {
|
||||
const service = createService({ flags: { met: true } });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
|
||||
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// Without an NPC there is no flag store to read, so the gate stays shut
|
||||
// rather than guessing.
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('treats an unset flag as false, which is what makes a greeting node work', async () => {
|
||||
// No state row at all: the character has never spoken to this NPC.
|
||||
const service = createService({ flags: null });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
|
||||
{ type: GameConditionType.FLAG_SET, key: 'met', value: false },
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('defaults HAS_ITEM to "at least one"', async () => {
|
||||
await expect(
|
||||
createService({ itemQuantity: 1 }).evaluate(
|
||||
{ characterId: CHARACTER_ID },
|
||||
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
createService({ itemQuantity: undefined }).evaluate(
|
||||
{ characterId: CHARACTER_ID },
|
||||
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('requires every condition to hold', async () => {
|
||||
const service = createService({ renown: 1, reputation: 100 });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 5,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a condition type nothing backs yet', async () => {
|
||||
// Quests arrive in Slice 0.9. Until then a quest gate must lock content,
|
||||
// never wave it through -- a gate that defaults open is not a gate.
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{ type: GameConditionType.QUEST_COMPLETED, key: 'anything' },
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when a condition names no target', async () => {
|
||||
const service = createService({ reputation: 999 });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 1,
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when the required value is not a number', async () => {
|
||||
const service = createService({ reputation: 50 });
|
||||
|
||||
await expect(
|
||||
service.evaluate({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 'lots',
|
||||
},
|
||||
]),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports each condition separately for a UI that explains a lock', async () => {
|
||||
const service = createService({ renown: 1, reputation: 100 });
|
||||
|
||||
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 5,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('reports the current value behind each requirement', async () => {
|
||||
const service = createService({ renown: 1, reputation: 14 });
|
||||
|
||||
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
// "Current: 14" against "Requires 25" is the whole point of showing a
|
||||
// locked offer rather than hiding it (slice §5).
|
||||
expect(outcomes[0]).toMatchObject({ met: false, actual: 14 });
|
||||
expect(outcomes[1]).toMatchObject({ met: false, actual: 1 });
|
||||
});
|
||||
|
||||
it('reports a null current value for a condition with no scale', async () => {
|
||||
const service = createService({ renown: 1, reputation: 100 });
|
||||
|
||||
const outcomes = await service.describe(
|
||||
{ characterId: CHARACTER_ID, npcId: NPC_ID },
|
||||
[{ type: GameConditionType.FLAG_SET, key: 'met', value: true }],
|
||||
);
|
||||
|
||||
expect(outcomes[0].actual).toBeNull();
|
||||
});
|
||||
});
|
||||
246
apps/api/src/conditions/game-condition.service.ts
Normal file
246
apps/api/src/conditions/game-condition.service.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from '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 { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
|
||||
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
compare,
|
||||
GameCondition,
|
||||
GameConditionType,
|
||||
SUPPORTED_CONDITION_TYPES,
|
||||
} from './game-condition.types';
|
||||
|
||||
/**
|
||||
* What a condition is being evaluated *about*.
|
||||
*
|
||||
* `npcId` scopes FLAG_SET, because a dialogue flag is per-NPC player state
|
||||
* (spec §7) rather than a global switch.
|
||||
*/
|
||||
export interface ConditionContext {
|
||||
characterId: string;
|
||||
npcId?: string;
|
||||
}
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
|
||||
/**
|
||||
* One condition's result, plus the number it was measured against.
|
||||
*
|
||||
* `actual` is null for conditions with no scale -- a flag is set or it is not,
|
||||
* and "Current: 0" would be a lie about a boolean.
|
||||
*/
|
||||
interface ConditionEvaluation {
|
||||
met: boolean;
|
||||
actual: number | null;
|
||||
}
|
||||
|
||||
export interface ConditionOutcome {
|
||||
condition: GameCondition;
|
||||
met: boolean;
|
||||
actual: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates content-defined conditions server-side (NPC spec §21).
|
||||
*
|
||||
* Reused by dialogue, shop offers and exchange rules so unlock logic exists
|
||||
* once. The client never evaluates a condition (spec §37.9); it is only ever
|
||||
* told the outcome.
|
||||
*
|
||||
* Everything fails closed. An unknown type, a missing key, a condition whose
|
||||
* backing system does not exist yet -- all read as "not met". For a gate, the
|
||||
* safe direction of a bug is locked, never opened.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GameConditionService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** True only when every condition holds. An empty list is always true. */
|
||||
async evaluate(
|
||||
context: ConditionContext,
|
||||
conditions: GameCondition[] | null | undefined,
|
||||
manager?: EntityManager,
|
||||
): Promise<boolean> {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||
for (const condition of conditions) {
|
||||
const evaluation = await this.evaluateOne(context, condition, scope);
|
||||
if (!evaluation.met) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports each condition individually, so a caller can say *why* something
|
||||
* is locked instead of only hiding it. Slice 0.8.5 builds its locked-offer
|
||||
* presentation on this.
|
||||
*/
|
||||
async describe(
|
||||
context: ConditionContext,
|
||||
conditions: GameCondition[] | null | undefined,
|
||||
manager?: EntityManager,
|
||||
): Promise<ConditionOutcome[]> {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||
const outcomes: ConditionOutcome[] = [];
|
||||
for (const condition of conditions) {
|
||||
const evaluation = await this.evaluateOne(context, condition, scope);
|
||||
outcomes.push({
|
||||
condition,
|
||||
met: evaluation.met,
|
||||
actual: evaluation.actual,
|
||||
});
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
private async evaluateOne(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
switch (condition.type) {
|
||||
case GameConditionType.REGION_REPUTATION:
|
||||
return this.evaluateRegionReputation(context, condition, scope);
|
||||
case GameConditionType.WORLD_RENOWN:
|
||||
return this.evaluateWorldRenown(context, condition, scope);
|
||||
case GameConditionType.FLAG_SET:
|
||||
return this.evaluateFlag(context, condition, scope);
|
||||
case GameConditionType.HAS_ITEM:
|
||||
return this.evaluateHasItem(context, condition, scope);
|
||||
default:
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateRegionReputation(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!condition.key) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const faction = await scope
|
||||
.getRepository(ReputationFaction)
|
||||
.findOneBy({ key: condition.key, enabled: true });
|
||||
if (!faction) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const row = await scope.getRepository(CharacterReputation).findOneBy({
|
||||
characterId: context.characterId,
|
||||
factionId: faction.id,
|
||||
});
|
||||
|
||||
// A faction the character never interacted with reads as 0, not as absent
|
||||
// -- the same rule ReputationService.getCharacterReputation applies.
|
||||
const reputation = row?.reputation ?? 0;
|
||||
return {
|
||||
met: this.compareNumeric(reputation, condition),
|
||||
actual: reputation,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateWorldRenown(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<ConditionEvaluation> {
|
||||
const character = await scope
|
||||
.getRepository(Character)
|
||||
.findOneBy({ id: context.characterId });
|
||||
if (!character) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
return {
|
||||
met: this.compareNumeric(character.renown, condition),
|
||||
actual: character.renown,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateFlag(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<ConditionEvaluation> {
|
||||
// Flags are per-NPC player state (spec §7). Without an NPC in context
|
||||
// there is nothing to read, so the gate stays shut.
|
||||
if (!condition.key || !context.npcId) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const state = await scope.getRepository(CharacterNpcState).findOneBy({
|
||||
characterId: context.characterId,
|
||||
npcId: context.npcId,
|
||||
});
|
||||
|
||||
const expected = condition.value ?? true;
|
||||
return {
|
||||
met: (state?.flags?.[condition.key] ?? false) === expected,
|
||||
actual: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateHasItem(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!condition.key) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const definition = await scope
|
||||
.getRepository(ItemDefinition)
|
||||
.findOneBy({ key: condition.key });
|
||||
if (!definition) {
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const owned = await scope.getRepository(CharacterItem).findOneBy({
|
||||
characterId: context.characterId,
|
||||
itemDefinitionId: definition.id,
|
||||
});
|
||||
|
||||
// "Has item" without an explicit comparison means "at least one".
|
||||
const quantity = owned?.quantity ?? 0;
|
||||
return {
|
||||
met: this.compareNumeric(quantity, {
|
||||
...condition,
|
||||
operator: condition.operator ?? ComparisonOperator.GTE,
|
||||
value: condition.value ?? 1,
|
||||
}),
|
||||
actual: quantity,
|
||||
};
|
||||
}
|
||||
|
||||
private compareNumeric(actual: number, condition: GameCondition): boolean {
|
||||
const expected = Number(condition.value);
|
||||
if (!Number.isFinite(expected)) {
|
||||
return false;
|
||||
}
|
||||
return compare(
|
||||
actual,
|
||||
condition.operator ?? ComparisonOperator.GTE,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
76
apps/api/src/conditions/game-condition.types.ts
Normal file
76
apps/api/src/conditions/game-condition.types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shared condition vocabulary (NPC spec §19, §20).
|
||||
*
|
||||
* One engine gates dialogue, shop offers and exchanges alike, so a new gate is
|
||||
* a row of content rather than another bespoke `if` in a service (spec §21,
|
||||
* "Keine separaten Condition-Systeme für jedes Feature bauen").
|
||||
*/
|
||||
export enum GameConditionType {
|
||||
QUEST_ACTIVE = 'QUEST_ACTIVE',
|
||||
QUEST_COMPLETED = 'QUEST_COMPLETED',
|
||||
HAS_ITEM = 'HAS_ITEM',
|
||||
REGION_REPUTATION = 'REGION_REPUTATION',
|
||||
WORLD_RENOWN = 'WORLD_RENOWN',
|
||||
FLAG_SET = 'FLAG_SET',
|
||||
BOSS_DEFEATED = 'BOSS_DEFEATED',
|
||||
LOCATION_DISCOVERED = 'LOCATION_DISCOVERED',
|
||||
}
|
||||
|
||||
export enum ComparisonOperator {
|
||||
EQ = 'EQ',
|
||||
NEQ = 'NEQ',
|
||||
GT = 'GT',
|
||||
GTE = 'GTE',
|
||||
LT = 'LT',
|
||||
LTE = 'LTE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored generically so content can express a new gate without a schema
|
||||
* change (spec §20). `key` names the target (a faction key, a flag name),
|
||||
* `operator`/`value` the comparison.
|
||||
*/
|
||||
export interface GameCondition {
|
||||
type: GameConditionType;
|
||||
key?: string;
|
||||
operator?: ComparisonOperator;
|
||||
value?: string | number | boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Condition types this build can actually answer.
|
||||
*
|
||||
* The remaining types are part of the V1 vocabulary (spec §19) but have no
|
||||
* backing system yet: quests arrive in Slice 0.9, bosses in 0.11, and location
|
||||
* discovery is not tracked per character at all. They are listed in the enum
|
||||
* so content and migrations do not need rewriting later, and rejected at
|
||||
* evaluation time so an unbacked gate can never silently read as "passed".
|
||||
*/
|
||||
export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
|
||||
new Set([
|
||||
GameConditionType.REGION_REPUTATION,
|
||||
GameConditionType.WORLD_RENOWN,
|
||||
GameConditionType.FLAG_SET,
|
||||
GameConditionType.HAS_ITEM,
|
||||
]);
|
||||
|
||||
export function compare(
|
||||
left: number,
|
||||
operator: ComparisonOperator,
|
||||
right: number,
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case ComparisonOperator.EQ:
|
||||
return left === right;
|
||||
case ComparisonOperator.NEQ:
|
||||
return left !== right;
|
||||
case ComparisonOperator.GT:
|
||||
return left > right;
|
||||
case ComparisonOperator.GTE:
|
||||
return left >= right;
|
||||
case ComparisonOperator.LT:
|
||||
return left < right;
|
||||
case ComparisonOperator.LTE:
|
||||
return left <= right;
|
||||
}
|
||||
}
|
||||
@@ -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,176 @@
|
||||
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,80 @@
|
||||
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,34 @@
|
||||
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,208 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateRenownAndReputation1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- Character: level/experience -> renown (spec §13, design R1/R6) ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "renown" integer NOT NULL DEFAULT 1',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'UPDATE "characters" SET "renown" = LEAST(GREATEST("level", 1), 15)',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "level"');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" DROP COLUMN "experience"',
|
||||
);
|
||||
|
||||
// --- ItemDefinition: drop requiredLevel (spec §14, design R4) ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
|
||||
);
|
||||
|
||||
// --- ItemType enum rebuild (spec §16, design R3): WEAPON/ARMOR -> ---
|
||||
// EQUIPMENT, MATERIAL -> TRADE_GOOD, add TROPHY/QUEST_ITEM. Postgres has
|
||||
// no ALTER TYPE ... RENAME VALUE across all supported versions here, so
|
||||
// this converts the column to text, migrates the data, rebuilds the
|
||||
// type, and converts back -- the same technique already used by
|
||||
// 1790000000000-ExtendCombatEventTypes.ts's down().
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "item_definitions" SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "item_definitions" SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`,
|
||||
);
|
||||
await queryRunner.query('DROP TYPE "item_type_enum"');
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
|
||||
);
|
||||
|
||||
// --- CombatReward: drop the XP audit column (spec §1, design R8) ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
|
||||
);
|
||||
|
||||
// --- MonsterDefinition: XP is abolished as a concept (spec §1, design R7).
|
||||
// `silver_min`/`silver_max` deliberately stay -- spec §15 keeps a direct
|
||||
// currency drop available as a lore-valid exception -- but XP has no such
|
||||
// carve-out, so the column goes with the concept. ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"',
|
||||
);
|
||||
|
||||
// --- ReputationFaction (spec §9) ---
|
||||
await queryRunner.query(`CREATE TABLE "reputation_factions" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"region_key" character varying(100) NOT NULL,
|
||||
"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_reputation_factions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_reputation_factions_key" ON "reputation_factions" ("key")',
|
||||
);
|
||||
|
||||
// --- CharacterReputation (spec §9) ---
|
||||
await queryRunner.query(`CREATE TABLE "character_reputation" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"faction_id" uuid NOT NULL,
|
||||
"reputation" integer NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_reputation" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_reputation_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_reputation_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
|
||||
);
|
||||
|
||||
// --- RenownMilestoneDefinition (spec §5) ---
|
||||
await queryRunner.query(`CREATE TABLE "renown_milestone_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,
|
||||
"renown_reward" integer NOT NULL,
|
||||
"repeatable" boolean NOT NULL DEFAULT false,
|
||||
"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_renown_milestone_definitions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key" ON "renown_milestone_definitions" ("key")',
|
||||
);
|
||||
|
||||
// --- CharacterRenownMilestone (spec §5) ---
|
||||
await queryRunner.query(`CREATE TABLE "character_renown_milestones" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"milestone_id" uuid NOT NULL,
|
||||
"completed_at" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"times_completed" integer NOT NULL DEFAULT 1,
|
||||
CONSTRAINT "PK_character_renown_milestones" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_renown_milestones_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_renown_milestones_milestone" FOREIGN KEY ("milestone_id") REFERENCES "renown_milestone_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
|
||||
);
|
||||
|
||||
// --- TurnInDefinition (spec §19) ---
|
||||
await queryRunner.query(`CREATE TABLE "turn_in_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"faction_id" uuid NOT NULL,
|
||||
"silver_reward_per_item" integer NOT NULL,
|
||||
"reputation_reward_per_item" integer NOT NULL,
|
||||
"repeatable" boolean NOT NULL DEFAULT true,
|
||||
"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_turn_in_definitions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_turn_in_definitions_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_turn_in_definitions_key"');
|
||||
await queryRunner.query('DROP TABLE "turn_in_definitions"');
|
||||
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_renown_milestones_character_milestone"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "character_renown_milestones"');
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_renown_milestone_definitions_key"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "renown_milestone_definitions"');
|
||||
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_reputation_character_faction"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "character_reputation"');
|
||||
await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"');
|
||||
await queryRunner.query('DROP TABLE "reputation_factions"');
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
|
||||
);
|
||||
await queryRunner.query('DROP TYPE "item_type_enum"');
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "item_definitions" SET "type" = 'MATERIAL' WHERE "type" = 'TRADE_GOOD'`,
|
||||
);
|
||||
// TROPHY/QUEST_ITEM/EQUIPMENT have no clean pre-image; best-effort revert
|
||||
// for genuinely disposable dev data (design R5).
|
||||
await queryRunner.query(
|
||||
`UPDATE "item_definitions" SET "type" = 'WEAPON' WHERE "type" IN ('EQUIPMENT', 'TROPHY', 'QUEST_ITEM')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
await queryRunner.query('UPDATE "characters" SET "level" = "renown"');
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"',
|
||||
);
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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,100 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CompleteBurnedRoad1793000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- Monster content: mechanics and atmosphere as data (spec §4, §8) ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb NOT NULL DEFAULT '{}'::jsonb`,
|
||||
);
|
||||
|
||||
// --- No direct currency from a kill (spec §7) ---
|
||||
// Slice 0.6.5 kept `silver_min`/`silver_max` as a deliberate carve-out for
|
||||
// a possible lore-valid direct drop. Playable Slice 0.7 V2 closes that:
|
||||
// normal kills grant no Silver at all, and Silver reaches the player
|
||||
// through merchant/turn-in exchange instead. Nothing reads these columns
|
||||
// any more, so they go rather than sit as a path that could quietly start
|
||||
// paying out again. Every seeded monster already had them at 0, so no
|
||||
// player balance moves with this.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
|
||||
);
|
||||
|
||||
// --- Status effects (spec §4: Bleeding) ---
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "status_effect_type_enum" AS ENUM ('BLEED')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_events" ADD COLUMN "status_effect" "status_effect_type_enum"',
|
||||
);
|
||||
// Postgres allows ADD VALUE inside a transaction as long as the new value
|
||||
// is not also used in it; this migration only declares them. Same
|
||||
// technique as 1790000000000-ExtendCombatEventTypes.ts.
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_APPLIED'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_DAMAGE'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_EXPIRED'`,
|
||||
);
|
||||
}
|
||||
|
||||
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, the same tradeoff the earlier enum migration makes.
|
||||
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', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', '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"',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_events" DROP COLUMN "status_effect"',
|
||||
);
|
||||
await queryRunner.query('DROP TYPE "status_effect_type_enum"');
|
||||
|
||||
// The dropped currency columns come back at 0 -- their pre-image was 0 for
|
||||
// every seeded monster, and no reward row recorded anything else.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_rewards" ALTER COLUMN "silver_granted" DROP DEFAULT',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_min" DROP DEFAULT',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_max" DROP DEFAULT',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
|
||||
);
|
||||
}
|
||||
}
|
||||
133
apps/api/src/database/migrations/1794000000000-CreateLootBags.ts
Normal file
133
apps/api/src/database/migrations/1794000000000-CreateLootBags.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateLootBags1794000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- Categories as content (spec §3, §5) ---
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "loot_category_enum" AS ENUM ('HIDE', 'RAIDER_TROPHY')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "monster_category_enum" AS ENUM ('BEAST', 'HUMANOID')`,
|
||||
);
|
||||
|
||||
// Nullable: only trade goods belong to a carrying bucket. Equipment and
|
||||
// consumables stay outside the system entirely (spec §8).
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" ADD COLUMN "loot_category" "loot_category_enum"',
|
||||
);
|
||||
|
||||
// Every monster has a category, so this backfills before going NOT NULL.
|
||||
// BEAST is the safe default for the pre-existing rows: the seed
|
||||
// immediately reclassifies the two humanoids by their stable keys, and a
|
||||
// wrong classification changes no behaviour in this slice — nothing
|
||||
// branches on monster category yet.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "monster_category" "monster_category_enum"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "monster_definitions" SET "monster_category" = 'BEAST' WHERE "monster_category" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "monster_definitions" SET "monster_category" = 'HUMANOID' WHERE "key" IN ('road-bandit', 'charred-looter')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ALTER COLUMN "monster_category" SET NOT NULL',
|
||||
);
|
||||
|
||||
// --- Bags (spec §6) ---
|
||||
await queryRunner.query(`CREATE TABLE "loot_bag_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"loot_category" "loot_category_enum" NOT NULL,
|
||||
"capacity" integer NOT NULL,
|
||||
"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_loot_bag_definitions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_loot_bag_definitions_capacity" CHECK ("capacity" >= 1)
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_loot_bag_definitions_key" ON "loot_bag_definitions" ("key")',
|
||||
);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE "character_loot_bags" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"loot_bag_definition_id" uuid NOT NULL,
|
||||
"active" 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_character_loot_bags" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_loot_bags_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_loot_bags_definition" FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
)`);
|
||||
// A character holds any given bag at most once. "One *active* bag per
|
||||
// category" is a service-level rule instead: the category lives on the
|
||||
// definition, and copying it here to get a partial unique index would
|
||||
// duplicate content into player state (AGENTS §7).
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition" ON "character_loot_bags" ("character_id", "loot_bag_definition_id")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_character_loot_bags_character" ON "character_loot_bags" ("character_id")',
|
||||
);
|
||||
|
||||
// --- Left-behind loot is part of the record (spec §9) ---
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
|
||||
);
|
||||
// A drop that was refused outright creates no stack to point at.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
|
||||
);
|
||||
// Slice 0.4 required quantity >= 1, because back then a reward row could
|
||||
// only mean "you got this". A fully refused drop is granted 0, so the
|
||||
// floor moves to 0 -- but the row must still record *something*, hence
|
||||
// the replacement constraint: no row may be all zeroes.
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 0 AND "quantity_left_behind" >= 0 AND "quantity" + "quantity_left_behind" >= 1)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Rows recording a fully-rejected drop have no character item and cannot
|
||||
// satisfy the restored NOT NULL, so they go with the feature that created
|
||||
// them. Nothing else can produce a null there.
|
||||
await queryRunner.query(
|
||||
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" SET NOT NULL',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "combat_reward_items" DROP COLUMN "quantity_left_behind"',
|
||||
);
|
||||
|
||||
await queryRunner.query('DROP INDEX "IDX_character_loot_bags_character"');
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_loot_bags_character_definition"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "character_loot_bags"');
|
||||
await queryRunner.query('DROP INDEX "IDX_loot_bag_definitions_key"');
|
||||
await queryRunner.query('DROP TABLE "loot_bag_definitions"');
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "monster_category"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "item_definitions" DROP COLUMN "loot_category"',
|
||||
);
|
||||
await queryRunner.query('DROP TYPE "monster_category_enum"');
|
||||
await queryRunner.query('DROP TYPE "loot_category_enum"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* The NPC system and the merchant trade-in loop
|
||||
* (NPC Specification V1 §29; Playable Slice 0.8).
|
||||
*
|
||||
* Also retires `turn_in_definitions` from Slice 0.6.5. Everything it could
|
||||
* sell is now sold through the merchant instead -- at a better price, and with
|
||||
* reputation and renown attached -- so the two are not left running as
|
||||
* parallel payout paths for the same pelt (slice 0.8 §6).
|
||||
*/
|
||||
export class CreateNpcSystem1795000000000 implements MigrationInterface {
|
||||
name = 'CreateNpcSystem1795000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "npc_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"title" character varying(150),
|
||||
"description" text,
|
||||
"location_id" uuid NOT NULL,
|
||||
"faction_key" character varying(100),
|
||||
"portrait_path" character varying(255) NOT NULL,
|
||||
"artwork_path" character varying(255),
|
||||
"capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"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_npc_definitions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_npc_definitions_location" FOREIGN KEY ("location_id")
|
||||
REFERENCES "location_definitions"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_npc_definitions_location" ON "npc_definitions" ("location_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "dialogue_nodes" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"npc_id" uuid NOT NULL,
|
||||
"key" character varying(100) NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"priority" integer NOT NULL,
|
||||
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"actions" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"responses" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"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_dialogue_nodes" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_dialogue_nodes_npc" FOREIGN KEY ("npc_id")
|
||||
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_dialogue_nodes_npc_key" ON "dialogue_nodes" ("npc_id", "key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_dialogue_nodes_npc_priority" ON "dialogue_nodes" ("npc_id", "priority")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "character_npc_states" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"npc_id" uuid NOT NULL,
|
||||
"first_met_at" TIMESTAMP WITH TIME ZONE,
|
||||
"last_interaction_at" TIMESTAMP WITH TIME ZONE,
|
||||
"flags" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_npc_states" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_npc_states_character" FOREIGN KEY ("character_id")
|
||||
REFERENCES "characters"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_character_npc_states_npc" FOREIGN KEY ("npc_id")
|
||||
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_character_npc_states_character_npc" ON "character_npc_states" ("character_id", "npc_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "npc_shops" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"npc_id" uuid NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"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_npc_shops" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_npc_shops_npc" FOREIGN KEY ("npc_id")
|
||||
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_npc_shops_npc" ON "npc_shops" ("npc_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "shop_offers" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"shop_id" uuid NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"currency_type" character varying(50) NOT NULL,
|
||||
"price" integer NOT NULL,
|
||||
"quantity" integer NOT NULL DEFAULT 1,
|
||||
"repeatable" boolean NOT NULL DEFAULT true,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"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_shop_offers" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_shop_offers_price" CHECK ("price" >= 0),
|
||||
CONSTRAINT "CHK_shop_offers_quantity" CHECK ("quantity" >= 1),
|
||||
CONSTRAINT "FK_shop_offers_shop" FOREIGN KEY ("shop_id")
|
||||
REFERENCES "npc_shops"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_shop_offers_item" FOREIGN KEY ("item_definition_id")
|
||||
REFERENCES "item_definitions"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "npc_exchange_profiles" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"npc_id" uuid NOT NULL,
|
||||
"name" character varying(150) NOT NULL,
|
||||
"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_npc_exchange_profiles" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_npc_exchange_profiles_npc" FOREIGN KEY ("npc_id")
|
||||
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_npc_exchange_profiles_npc" ON "npc_exchange_profiles" ("npc_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "exchange_rules" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"profile_id" uuid NOT NULL,
|
||||
"input_item_id" uuid NOT NULL,
|
||||
"input_quantity" integer NOT NULL DEFAULT 1,
|
||||
"faction_id" uuid NOT NULL,
|
||||
"silver_reward" integer NOT NULL DEFAULT 0,
|
||||
"region_reputation_reward" integer NOT NULL DEFAULT 0,
|
||||
"renown_milestone_key" character varying(100),
|
||||
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"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_exchange_rules" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_exchange_rules_input_quantity" CHECK ("input_quantity" >= 1),
|
||||
CONSTRAINT "CHK_exchange_rules_rewards" CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0),
|
||||
CONSTRAINT "FK_exchange_rules_profile" FOREIGN KEY ("profile_id")
|
||||
REFERENCES "npc_exchange_profiles"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_exchange_rules_item" FOREIGN KEY ("input_item_id")
|
||||
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_exchange_rules_faction" FOREIGN KEY ("faction_id")
|
||||
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")`,
|
||||
);
|
||||
|
||||
// Retires Slice 0.6.5's `turn_in_definitions`.
|
||||
//
|
||||
// Nothing is carried across: exchange rules are seeded content, and the
|
||||
// seed re-establishes an equivalent (better paying, reputation- and
|
||||
// renown-aware) rule for every item that used to be turned in. Migrations
|
||||
// run before seeds, so there is no exchange profile to attach carried rows
|
||||
// to at this point anyway.
|
||||
//
|
||||
// Dropped rather than left standing, because a dormant second payout path
|
||||
// for the same pelts is exactly the competing progression model slice 0.8
|
||||
// §6 rules out -- and a table nothing reads is one someone re-wires later.
|
||||
await queryRunner.query(`DROP TABLE "turn_in_definitions"`);
|
||||
|
||||
// Trading the last of a stack deletes the `character_items` row, and
|
||||
// Slice 0.4 pinned reward bookkeeping to it with ON DELETE RESTRICT --
|
||||
// correct when nothing could ever consume a stack, and a hard 500 the
|
||||
// moment something could. This slice is that something.
|
||||
//
|
||||
// SET NULL keeps the reward history intact (it still records what
|
||||
// dropped) while letting the stack itself go. The column has been
|
||||
// nullable since 0.7.5, which already used null to mean "no live stack".
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "combat_reward_items"
|
||||
DROP CONSTRAINT "FK_combat_reward_items_character_item"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "combat_reward_items"
|
||||
ADD CONSTRAINT "FK_combat_reward_items_character_item"
|
||||
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Rebuilt exactly as migration 1791 left it, so a rollback lands on the
|
||||
// schema that migration expects to own.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "turn_in_definitions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"key" character varying(100) NOT NULL,
|
||||
"item_definition_id" uuid NOT NULL,
|
||||
"faction_id" uuid NOT NULL,
|
||||
"silver_reward_per_item" integer NOT NULL,
|
||||
"reputation_reward_per_item" integer NOT NULL,
|
||||
"repeatable" boolean NOT NULL DEFAULT true,
|
||||
"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_turn_in_definitions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_turn_in_definitions_item" FOREIGN KEY ("item_definition_id")
|
||||
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id")
|
||||
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "combat_reward_items"
|
||||
DROP CONSTRAINT "FK_combat_reward_items_character_item"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "combat_reward_items"
|
||||
ADD CONSTRAINT "FK_combat_reward_items_character_item"
|
||||
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
|
||||
ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "exchange_rules"`);
|
||||
await queryRunner.query(`DROP TABLE "npc_exchange_profiles"`);
|
||||
await queryRunner.query(`DROP TABLE "shop_offers"`);
|
||||
await queryRunner.query(`DROP TABLE "npc_shops"`);
|
||||
await queryRunner.query(`DROP TABLE "character_npc_states"`);
|
||||
await queryRunner.query(`DROP TABLE "dialogue_nodes"`);
|
||||
await queryRunner.query(`DROP TABLE "npc_definitions"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Lets a shop offer sell a loot bag, and lets one offer carry an exception
|
||||
* (Playable Slice 0.8.5 §4, §7).
|
||||
*
|
||||
* A bag is a `loot_bag_definitions` row, not an item -- deliberately so since
|
||||
* Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has
|
||||
* no combat stats. Selling one therefore needs a second target column rather
|
||||
* than a fake item definition, which is exactly the "offer shape" the Slice 0.8
|
||||
* seed said it had no reason to build yet.
|
||||
*
|
||||
* `bypass_conditions` is the minimal exception support Slice 0.9 needs: an
|
||||
* offer opens when `conditions` hold *or* `bypass_conditions` hold. One column,
|
||||
* OR semantics, no rule engine (slice §7, §11).
|
||||
*/
|
||||
export class SellableLootBags1796000000000 implements MigrationInterface {
|
||||
name = 'SellableLootBags1796000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Shop offers are pure content -- nothing references them, and Slice 0.8
|
||||
// inserted its two rows with generated uuids. The seed re-creates every
|
||||
// offer with a stable id (AGENTS.md §8), which needs the old anonymous
|
||||
// rows gone or they would linger as duplicates the upsert never matches.
|
||||
await queryRunner.query(`DELETE FROM "shop_offers"`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ALTER COLUMN "item_definition_id" DROP NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ADD COLUMN "loot_bag_definition_id" uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ADD CONSTRAINT "FK_shop_offers_loot_bag"
|
||||
FOREIGN KEY ("loot_bag_definition_id")
|
||||
REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
`);
|
||||
|
||||
// Exactly one target. An offer selling nothing has no meaning, and one
|
||||
// selling both would make the grant path ambiguous.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ADD CONSTRAINT "CHK_shop_offers_single_target"
|
||||
CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1)
|
||||
`);
|
||||
|
||||
// Partial, because the column each one covers is now nullable and Postgres
|
||||
// treats NULLs as distinct -- a plain unique index over (shop_id,
|
||||
// item_definition_id) would happily accept a hundred bag offers.
|
||||
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Bag offers cannot survive a rollback: the column that identifies what
|
||||
// they sell is about to disappear, and the restored NOT NULL would reject
|
||||
// them anyway.
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
DROP CONSTRAINT "CHK_shop_offers_single_target"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
DROP CONSTRAINT "FK_shop_offers_loot_bag"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "shop_offers"
|
||||
ALTER COLUMN "item_definition_id" SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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,63 @@
|
||||
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,195 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { CompleteBurnedRoad1793000000000 } from './1793000000000-CompleteBurnedRoad';
|
||||
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
||||
import { CombatEventType } from '../../combat/combat-event-type.enum';
|
||||
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { StatusEffectType } from '../../combat/status-effect.enum';
|
||||
|
||||
describe('CompleteBurnedRoad1793000000000', () => {
|
||||
async function runUp() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
await new CompleteBurnedRoad1793000000000().up(queryRunner);
|
||||
return query.mock.calls.map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
async function runDown() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CompleteBurnedRoad1793000000000();
|
||||
await migration.up(queryRunner);
|
||||
const upCount = query.mock.calls.length;
|
||||
await migration.down(queryRunner);
|
||||
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
it('adds the monster content columns the slice drives its mechanics from', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults abilities to an empty object so existing monsters stay plain attackers', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const abilities = up.find((sql) => sql.includes('"abilities"'));
|
||||
expect(abilities).toContain("DEFAULT '{}'::jsonb");
|
||||
expect(abilities).toContain('NOT NULL');
|
||||
});
|
||||
|
||||
it('drops every direct currency reward path from a kill', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
// Spec §7: a normal kill grants no Silver. Leaving the columns in place
|
||||
// would leave a path that could quietly start paying out again.
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the status effect type and hangs it off combat_events', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const createIndex = up.findIndex((sql) =>
|
||||
sql.includes('CREATE TYPE "status_effect_type_enum"'),
|
||||
);
|
||||
const columnIndex = up.findIndex((sql) =>
|
||||
sql.includes('ADD COLUMN "status_effect"'),
|
||||
);
|
||||
|
||||
expect(createIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(columnIndex).toBeGreaterThanOrEqual(0);
|
||||
// The column cannot reference a type that does not exist yet.
|
||||
expect(createIndex).toBeLessThan(columnIndex);
|
||||
});
|
||||
|
||||
it('extends the combat event enum with the status effect events', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
for (const value of ['STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED']) {
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`ALTER TYPE "combat_event_type_enum" ADD VALUE '${value}'`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rebuilds the combat event enum without the new values on the way down', async () => {
|
||||
const down = await runDown();
|
||||
|
||||
const rebuilt = down.find((sql) =>
|
||||
sql.includes('CREATE TYPE "combat_event_type_enum"'),
|
||||
);
|
||||
expect(rebuilt).toBeDefined();
|
||||
expect(rebuilt).not.toContain('STATUS_APPLIED');
|
||||
// Everything the previous migrations added must survive the rollback.
|
||||
for (const value of [
|
||||
'DAMAGE',
|
||||
'HEAL',
|
||||
'DEFEND',
|
||||
'TELEGRAPH',
|
||||
'INTERRUPT',
|
||||
'COMBAT_WON',
|
||||
'COMBAT_LOST',
|
||||
]) {
|
||||
expect(rebuilt).toContain(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores every dropped column on the way down', async () => {
|
||||
const down = await runDown();
|
||||
|
||||
expect(down).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(down).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('DROP TYPE "status_effect_type_enum"'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice 0.7 entity schema', () => {
|
||||
function column(target: unknown, propertyName: string) {
|
||||
return getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === target && candidate.propertyName === propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
it('stores monster abilities as jsonb', () => {
|
||||
expect(column(MonsterDefinition, 'abilities')?.options.type).toBe('jsonb');
|
||||
});
|
||||
|
||||
it('allows a monster without a flavor line', () => {
|
||||
const flavorText = column(MonsterDefinition, 'flavorText');
|
||||
expect(flavorText?.options.type).toBe('text');
|
||||
expect(flavorText?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('no longer models a currency range on a monster', () => {
|
||||
expect(column(MonsterDefinition, 'silverMin')).toBeUndefined();
|
||||
expect(column(MonsterDefinition, 'silverMax')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no longer models granted Silver on a combat reward', () => {
|
||||
expect(column(CombatReward, 'silverGranted')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tags a combat event with the status effect it concerns', () => {
|
||||
const statusEffect = column(CombatEvent, 'statusEffect');
|
||||
expect(statusEffect?.options.enum).toBe(StatusEffectType);
|
||||
expect(statusEffect?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the status effect event types', () => {
|
||||
expect(Object.values(CombatEventType)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'STATUS_APPLIED',
|
||||
'STATUS_DAMAGE',
|
||||
'STATUS_EXPIRED',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
53
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
53
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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,28 @@
|
||||
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,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,211 @@
|
||||
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,
|
||||
}),
|
||||
// SET NULL since Slice 0.8: trading the last of a stack deletes the
|
||||
// character_items row, and RESTRICT made that fail. The reward record
|
||||
// survives with a null pointer rather than pinning the stack forever.
|
||||
expect.objectContaining({
|
||||
onDelete: 'SET NULL',
|
||||
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"',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
223
apps/api/src/database/migrations/loot-bags.migration.spec.ts
Normal file
223
apps/api/src/database/migrations/loot-bags.migration.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { CreateLootBags1794000000000 } from './1794000000000-CreateLootBags';
|
||||
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
|
||||
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
import { MonsterCategory } from '../../monsters/monster-category.enum';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
|
||||
describe('CreateLootBags1794000000000', () => {
|
||||
async function runUp() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
await new CreateLootBags1794000000000().up(queryRunner);
|
||||
return query.mock.calls.map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
async function runDown() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateLootBags1794000000000();
|
||||
await migration.up(queryRunner);
|
||||
const upCount = query.mock.calls.length;
|
||||
await migration.down(queryRunner);
|
||||
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
it('creates both category enums before the columns that use them', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const lootTypeIndex = up.findIndex((sql) =>
|
||||
sql.includes('CREATE TYPE "loot_category_enum"'),
|
||||
);
|
||||
const lootColumnIndex = up.findIndex((sql) =>
|
||||
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
|
||||
);
|
||||
const monsterTypeIndex = up.findIndex((sql) =>
|
||||
sql.includes('CREATE TYPE "monster_category_enum"'),
|
||||
);
|
||||
const monsterColumnIndex = up.findIndex((sql) =>
|
||||
sql.includes('"monster_definitions" ADD COLUMN "monster_category"'),
|
||||
);
|
||||
|
||||
expect(lootTypeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(monsterTypeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(lootTypeIndex).toBeLessThan(lootColumnIndex);
|
||||
expect(monsterTypeIndex).toBeLessThan(monsterColumnIndex);
|
||||
});
|
||||
|
||||
it('leaves loot_category nullable, because only trade goods have one', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const column = up.find((sql) =>
|
||||
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
|
||||
);
|
||||
expect(column).not.toContain('NOT NULL');
|
||||
});
|
||||
|
||||
it('backfills monster_category BEFORE making it NOT NULL', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const backfillIndex = up.findIndex((sql) =>
|
||||
sql.includes(`SET "monster_category" = 'BEAST'`),
|
||||
);
|
||||
const humanoidIndex = up.findIndex((sql) =>
|
||||
sql.includes(`SET "monster_category" = 'HUMANOID'`),
|
||||
);
|
||||
const notNullIndex = up.findIndex((sql) =>
|
||||
sql.includes('"monster_category" SET NOT NULL'),
|
||||
);
|
||||
|
||||
expect(backfillIndex).toBeGreaterThanOrEqual(0);
|
||||
// Reversing these would fail the migration on any existing row.
|
||||
expect(backfillIndex).toBeLessThan(notNullIndex);
|
||||
expect(humanoidIndex).toBeLessThan(notNullIndex);
|
||||
});
|
||||
|
||||
it('creates the bag tables with their ownership keys', async () => {
|
||||
const up = await runUp();
|
||||
const joined = up.join('\n');
|
||||
|
||||
expect(joined).toContain('CREATE TABLE "loot_bag_definitions"');
|
||||
expect(joined).toContain('CREATE TABLE "character_loot_bags"');
|
||||
// A character cannot hold the same bag twice.
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition"',
|
||||
);
|
||||
// Deleting a character takes their bags; a bag definition in use cannot
|
||||
// be deleted out from under them.
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a bag that carries nothing', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up.join('\n')).toContain('CHECK ("capacity" >= 1)');
|
||||
});
|
||||
|
||||
it('lets a reward item record a drop that was refused outright', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'"combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'"combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('lowers the quantity floor to 0 but still forbids an empty row', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const constraint = up
|
||||
.filter((sql) => sql.includes('CHK_combat_reward_items_quantity'))
|
||||
.join(' ');
|
||||
|
||||
// Slice 0.4's `quantity >= 1` would reject a fully refused drop outright,
|
||||
// which is how this surfaced: the victory 500'd instead of recording what
|
||||
// was left behind.
|
||||
expect(constraint).toContain('DROP CONSTRAINT');
|
||||
expect(constraint).toContain('"quantity" >= 0');
|
||||
// A row still has to mean something: granted 0 and refused 0 is nonsense.
|
||||
expect(constraint).toContain('"quantity" + "quantity_left_behind" >= 1');
|
||||
});
|
||||
|
||||
it('restores the original quantity floor on the way down', async () => {
|
||||
const down = await runDown();
|
||||
|
||||
const restored = down.find((sql) =>
|
||||
sql.includes('ADD CONSTRAINT "CHK_combat_reward_items_quantity"'),
|
||||
);
|
||||
expect(restored).toContain('"quantity" >= 1');
|
||||
expect(restored).not.toContain('quantity_left_behind');
|
||||
});
|
||||
|
||||
it('clears the rows that cannot satisfy the restored NOT NULL on the way down', async () => {
|
||||
const down = await runDown();
|
||||
|
||||
const deleteIndex = down.findIndex((sql) =>
|
||||
sql.includes(
|
||||
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
|
||||
),
|
||||
);
|
||||
const notNullIndex = down.findIndex((sql) =>
|
||||
sql.includes('"character_item_id" SET NOT NULL'),
|
||||
);
|
||||
|
||||
expect(deleteIndex).toBeGreaterThanOrEqual(0);
|
||||
// Without the delete first, the rollback would simply fail.
|
||||
expect(deleteIndex).toBeLessThan(notNullIndex);
|
||||
});
|
||||
|
||||
it('drops the enum types only after the columns using them are gone', async () => {
|
||||
const down = await runDown();
|
||||
|
||||
const dropColumnIndex = down.findIndex((sql) =>
|
||||
sql.includes('"item_definitions" DROP COLUMN "loot_category"'),
|
||||
);
|
||||
const dropTypeIndex = down.findIndex((sql) =>
|
||||
sql.includes('DROP TYPE "loot_category_enum"'),
|
||||
);
|
||||
const dropBagTableIndex = down.findIndex((sql) =>
|
||||
sql.includes('DROP TABLE "loot_bag_definitions"'),
|
||||
);
|
||||
|
||||
expect(dropColumnIndex).toBeLessThan(dropTypeIndex);
|
||||
// loot_bag_definitions.loot_category uses the same type.
|
||||
expect(dropBagTableIndex).toBeLessThan(dropTypeIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice 0.7.5 entity schema', () => {
|
||||
function column(target: unknown, propertyName: string) {
|
||||
return getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === target && candidate.propertyName === propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
it('lets an item definition sit outside every loot category', () => {
|
||||
const lootCategory = column(ItemDefinition, 'lootCategory');
|
||||
expect(lootCategory?.options.enum).toBe(LootCategory);
|
||||
expect(lootCategory?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a category on every monster definition', () => {
|
||||
const monsterCategory = column(MonsterDefinition, 'monsterCategory');
|
||||
expect(monsterCategory?.options.enum).toBe(MonsterCategory);
|
||||
expect(monsterCategory?.options.nullable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('models a bag definition as content with a category and a capacity', () => {
|
||||
expect(column(LootBagDefinition, 'lootCategory')?.options.enum).toBe(
|
||||
LootCategory,
|
||||
);
|
||||
expect(column(LootBagDefinition, 'capacity')?.options.type).toBe('integer');
|
||||
});
|
||||
|
||||
it('models an owned bag as active-or-not player state', () => {
|
||||
expect(column(CharacterLootBag, 'active')?.options.type).toBe('boolean');
|
||||
expect(column(CharacterLootBag, 'characterId')?.options.type).toBe('uuid');
|
||||
});
|
||||
|
||||
it('lets a reward item point at no character item', () => {
|
||||
expect(column(CombatRewardItem, 'characterItemId')?.options.nullable).toBe(
|
||||
true,
|
||||
);
|
||||
expect(column(CombatRewardItem, 'quantityLeftBehind')?.options.type).toBe(
|
||||
'integer',
|
||||
);
|
||||
});
|
||||
});
|
||||
195
apps/api/src/database/migrations/npc-system.migration.spec.ts
Normal file
195
apps/api/src/database/migrations/npc-system.migration.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { CreateNpcSystem1795000000000 } from './1795000000000-CreateNpcSystem';
|
||||
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
|
||||
import { CharacterNpcState } from '../../npcs/entities/character-npc-state.entity';
|
||||
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
|
||||
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
import { NpcShop } from '../../shops/entities/npc-shop.entity';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
|
||||
async function runUp(): Promise<string[]> {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
await new CreateNpcSystem1795000000000().up(queryRunner);
|
||||
return query.mock.calls.map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
async function runDown(): Promise<string[]> {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateNpcSystem1795000000000();
|
||||
await migration.up(queryRunner);
|
||||
const upCount = query.mock.calls.length;
|
||||
await migration.down(queryRunner);
|
||||
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
describe('CreateNpcSystem1795000000000', () => {
|
||||
it('creates every NPC-system table', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
for (const table of [
|
||||
'npc_definitions',
|
||||
'dialogue_nodes',
|
||||
'character_npc_states',
|
||||
'npc_shops',
|
||||
'shop_offers',
|
||||
'npc_exchange_profiles',
|
||||
'exchange_rules',
|
||||
]) {
|
||||
expect(joined).toContain(`CREATE TABLE "${table}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates parent tables before the children that reference them', async () => {
|
||||
const up = await runUp();
|
||||
const indexOf = (needle: string) =>
|
||||
up.findIndex((sql) => sql.includes(needle));
|
||||
|
||||
const npcs = indexOf('CREATE TABLE "npc_definitions"');
|
||||
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "dialogue_nodes"'));
|
||||
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_shops"'));
|
||||
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_exchange_profiles"'));
|
||||
expect(indexOf('CREATE TABLE "npc_shops"')).toBeLessThan(
|
||||
indexOf('CREATE TABLE "shop_offers"'),
|
||||
);
|
||||
expect(indexOf('CREATE TABLE "npc_exchange_profiles"')).toBeLessThan(
|
||||
indexOf('CREATE TABLE "exchange_rules"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every NPC-facing row a stable unique business key (spec §4)', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps one exchange rule per item, so a good has one price', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses content that would pay out nonsense', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
// A rule trading zero items would loop forever against any stack.
|
||||
expect(joined).toContain('CHECK ("input_quantity" >= 1)');
|
||||
expect(joined).toContain(
|
||||
'CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0)',
|
||||
);
|
||||
expect(joined).toContain('CHECK ("price" >= 0)');
|
||||
});
|
||||
|
||||
it('cascades player-owned rows and protects referenced content', async () => {
|
||||
// Constraints are written across two lines for readability, so compare
|
||||
// against whitespace-collapsed SQL rather than the literal formatting.
|
||||
const joined = (await runUp()).join('\n').replace(/\s+/g, ' ');
|
||||
|
||||
// Deleting a character takes their NPC state with it.
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
|
||||
);
|
||||
// An item that is priced somewhere cannot be deleted out from under it.
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("input_item_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT',
|
||||
);
|
||||
});
|
||||
|
||||
it('retires the turn-in table that the exchange replaces', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
// Slice 0.6.5's turn-in and this exchange both convert a pelt into silver
|
||||
// and reputation. Leaving both would be two prices for one pelt
|
||||
// (slice 0.8 §6).
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds the turn-in table on the way down', async () => {
|
||||
const down = await runDown();
|
||||
const joined = down.join('\n');
|
||||
|
||||
expect(joined).toContain('CREATE TABLE "turn_in_definitions"');
|
||||
expect(joined).toContain('"silver_reward_per_item" integer NOT NULL');
|
||||
});
|
||||
|
||||
it('drops children before parents on the way down', async () => {
|
||||
const down = await runDown();
|
||||
const indexOf = (needle: string) =>
|
||||
down.findIndex((sql) => sql.includes(needle));
|
||||
|
||||
expect(indexOf('DROP TABLE "exchange_rules"')).toBeLessThan(
|
||||
indexOf('DROP TABLE "npc_exchange_profiles"'),
|
||||
);
|
||||
expect(indexOf('DROP TABLE "shop_offers"')).toBeLessThan(
|
||||
indexOf('DROP TABLE "npc_shops"'),
|
||||
);
|
||||
expect(indexOf('DROP TABLE "dialogue_nodes"')).toBeLessThan(
|
||||
indexOf('DROP TABLE "npc_definitions"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice 0.8 entity schema', () => {
|
||||
function column(target: unknown, propertyName: string) {
|
||||
return getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === target && candidate.propertyName === propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
it('stores NPC capabilities as data rather than as subclasses (spec §2)', () => {
|
||||
expect(column(NpcDefinition, 'capabilities')?.options.type).toBe('jsonb');
|
||||
});
|
||||
|
||||
it('lets a dialogue node carry its own conditions and priority (spec §11)', () => {
|
||||
expect(column(DialogueNode, 'priority')?.options.type).toBe('integer');
|
||||
expect(column(DialogueNode, 'conditions')?.options.type).toBe('jsonb');
|
||||
});
|
||||
|
||||
it('keeps per-character NPC state off the shared definition (spec §7)', () => {
|
||||
expect(column(CharacterNpcState, 'characterId')?.options.type).toBe('uuid');
|
||||
expect(column(CharacterNpcState, 'flags')?.options.type).toBe('jsonb');
|
||||
// Personal relationship is explicitly out of V1 scope (spec §35).
|
||||
expect(column(CharacterNpcState, 'relationValue')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives every shop offer its own conditions (spec §16)', () => {
|
||||
expect(column(ShopOffer, 'conditions')?.options.type).toBe('jsonb');
|
||||
});
|
||||
|
||||
it('names a renown milestone instead of paying renown per item', () => {
|
||||
// Renown is a 1-15 power rank recomputed from a curve (Slice 0.6.5 §4),
|
||||
// so it is awarded by milestone, not accumulated per pelt.
|
||||
const milestone = column(ExchangeRule, 'renownMilestoneKey');
|
||||
expect(milestone?.options.nullable).toBe(true);
|
||||
expect(column(ExchangeRule, 'silverReward')?.options.type).toBe('integer');
|
||||
});
|
||||
|
||||
it('keeps the exchange profile separate from the shop (spec §17)', () => {
|
||||
expect(column(NpcExchangeProfile, 'key')?.options.type).toBe('varchar');
|
||||
expect(column(NpcShop, 'key')?.options.type).toBe('varchar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { ItemType } from '../../items/item-type.enum';
|
||||
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { CharacterReputation } from '../../reputation/entities/character-reputation.entity';
|
||||
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
||||
import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity';
|
||||
|
||||
function columnNames(target: unknown): string[] {
|
||||
return getMetadataArgsStorage()
|
||||
.columns.filter((column) => column.target === target)
|
||||
.map((column) => column.propertyName);
|
||||
}
|
||||
|
||||
function uniqueIndexFor(target: unknown, columns: string[]): boolean {
|
||||
const index = getMetadataArgsStorage().indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === target &&
|
||||
columns.every((column) => candidate.columns?.includes(column)),
|
||||
);
|
||||
const meta = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
return (meta?.options?.unique ?? meta?.unique) === true;
|
||||
}
|
||||
|
||||
describe('Slice 0.6.5 entity metadata', () => {
|
||||
it('Character exposes renown and no longer exposes level/experience', () => {
|
||||
const names = columnNames(Character);
|
||||
expect(names).toContain('renown');
|
||||
expect(names).not.toContain('level');
|
||||
expect(names).not.toContain('experience');
|
||||
});
|
||||
|
||||
it('ItemDefinition no longer exposes requiredLevel', () => {
|
||||
expect(columnNames(ItemDefinition)).not.toContain('requiredLevel');
|
||||
});
|
||||
|
||||
it('ItemType has exactly the five Slice 0.6.5 values', () => {
|
||||
expect(Object.values(ItemType).sort()).toEqual(
|
||||
['CONSUMABLE', 'EQUIPMENT', 'QUEST_ITEM', 'TRADE_GOOD', 'TROPHY'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('CombatReward no longer exposes experienceGranted', () => {
|
||||
expect(columnNames(CombatReward)).not.toContain('experienceGranted');
|
||||
});
|
||||
|
||||
it('ReputationFaction has a unique key', () => {
|
||||
expect(uniqueIndexFor(ReputationFaction, ['key'])).toBe(true);
|
||||
});
|
||||
|
||||
it('CharacterReputation enforces one row per character per faction', () => {
|
||||
expect(
|
||||
uniqueIndexFor(CharacterReputation, ['characterId', 'factionId']),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('RenownMilestoneDefinition has a unique key', () => {
|
||||
expect(uniqueIndexFor(RenownMilestoneDefinition, ['key'])).toBe(true);
|
||||
});
|
||||
|
||||
it('CharacterRenownMilestone enforces one row per character per milestone', () => {
|
||||
expect(
|
||||
uniqueIndexFor(CharacterRenownMilestone, ['characterId', 'milestoneId']),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// TurnInDefinition's metadata assertion lived here until Slice 0.8 retired
|
||||
// that entity in favour of ExchangeRule. Its replacement is covered by
|
||||
// `npc-system.migration.spec.ts`.
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
import { CreateRenownAndReputation1791000000000 } from './1791000000000-CreateRenownAndReputation';
|
||||
|
||||
describe('CreateRenownAndReputation1791000000000', () => {
|
||||
async function runUp() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateRenownAndReputation1791000000000();
|
||||
await migration.up(queryRunner);
|
||||
return query.mock.calls.map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
async function runUpThenDown() {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new CreateRenownAndReputation1791000000000();
|
||||
await migration.up(queryRunner);
|
||||
const upCount = query.mock.calls.length;
|
||||
await migration.down(queryRunner);
|
||||
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
it('replaces level/experience with a renown column on characters', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "renown"'),
|
||||
expect.stringContaining('DROP COLUMN "level"'),
|
||||
expect.stringContaining('DROP COLUMN "experience"'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills renown from level BEFORE dropping the level column', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const backfillIndex = up.findIndex((sql) =>
|
||||
sql.includes('LEAST(GREATEST("level", 1), 15)'),
|
||||
);
|
||||
const dropLevelIndex = up.findIndex((sql) =>
|
||||
sql.includes('DROP COLUMN "level"'),
|
||||
);
|
||||
|
||||
expect(backfillIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(dropLevelIndex).toBeGreaterThanOrEqual(0);
|
||||
// Reversing these two would silently discard every character's progression.
|
||||
expect(backfillIndex).toBeLessThan(dropLevelIndex);
|
||||
});
|
||||
|
||||
it('adds the renown range constraint only AFTER the backfill has populated valid values', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
const backfillIndex = up.findIndex((sql) =>
|
||||
sql.includes('LEAST(GREATEST("level", 1), 15)'),
|
||||
);
|
||||
const constraintIndex = up.findIndex((sql) =>
|
||||
sql.includes('CHK_characters_renown'),
|
||||
);
|
||||
|
||||
expect(backfillIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(constraintIndex).toBeGreaterThanOrEqual(0);
|
||||
// ADD CONSTRAINT validates the whole table; running it before the backfill
|
||||
// would only pass by coincidence of the column DEFAULT being in range.
|
||||
expect(backfillIndex).toBeLessThan(constraintIndex);
|
||||
});
|
||||
|
||||
it('drops required_level from item_definitions', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds item_type_enum with exactly the five Slice 0.6.5 values, migrating existing rows', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`,
|
||||
),
|
||||
expect.stringContaining(
|
||||
`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`,
|
||||
),
|
||||
expect.stringContaining('DROP TYPE "item_type_enum"'),
|
||||
expect.stringContaining(
|
||||
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops experience_granted from combat_rewards', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* XP is abolished outright (design R7), so the column goes with the concept.
|
||||
* It is NOT NULL with no default, so leaving it behind while the seed stops
|
||||
* supplying a value would fail every monster insert against a real database
|
||||
* -- a break no suite here can catch, since none of them connect to Postgres.
|
||||
*/
|
||||
it('drops experience_reward from monster_definitions', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps silver_min and silver_max on monster_definitions', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up.join(' ')).not.toContain('"silver_min"');
|
||||
expect(up.join(' ')).not.toContain('"silver_max"');
|
||||
});
|
||||
|
||||
it('creates all five new tables with their unique constraints', async () => {
|
||||
const up = await runUp();
|
||||
|
||||
expect(up).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('CREATE TABLE "reputation_factions"'),
|
||||
expect.stringContaining(
|
||||
'CREATE UNIQUE INDEX "IDX_reputation_factions_key"',
|
||||
),
|
||||
expect.stringContaining('CREATE TABLE "character_reputation"'),
|
||||
expect.stringContaining(
|
||||
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
|
||||
),
|
||||
expect.stringContaining('CREATE TABLE "renown_milestone_definitions"'),
|
||||
expect.stringContaining(
|
||||
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"',
|
||||
),
|
||||
expect.stringContaining('CREATE TABLE "character_renown_milestones"'),
|
||||
expect.stringContaining(
|
||||
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
|
||||
),
|
||||
expect.stringContaining('CREATE TABLE "turn_in_definitions"'),
|
||||
expect.stringContaining(
|
||||
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('down() reverses every up() step in exact opposite order, dropping the five new tables first', async () => {
|
||||
const down = await runUpThenDown();
|
||||
|
||||
expect(down).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
|
||||
expect.stringContaining('DROP TABLE "character_renown_milestones"'),
|
||||
expect.stringContaining('DROP TABLE "renown_milestone_definitions"'),
|
||||
expect.stringContaining('DROP TABLE "character_reputation"'),
|
||||
expect.stringContaining('DROP TABLE "reputation_factions"'),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "item_definitions" ADD COLUMN "required_level"',
|
||||
),
|
||||
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "level"'),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "characters" ADD COLUMN "experience"',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'ALTER TABLE "characters" DROP COLUMN "renown"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
const turnInDropIndex = down.findIndex((sql) =>
|
||||
sql.includes('DROP TABLE "turn_in_definitions"'),
|
||||
);
|
||||
const reputationFactionsDropIndex = down.findIndex((sql) =>
|
||||
sql.includes('DROP TABLE "reputation_factions"'),
|
||||
);
|
||||
|
||||
expect(turnInDropIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(reputationFactionsDropIndex).toBeGreaterThanOrEqual(0);
|
||||
// turn_in_definitions has FK dependencies on reputation_factions and
|
||||
// item_definitions that must be gone before those tables are touched.
|
||||
expect(turnInDropIndex).toBeLessThan(reputationFactionsDropIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
|
||||
async function runUp(): Promise<string[]> {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
await new SellableLootBags1796000000000().up(queryRunner);
|
||||
return query.mock.calls.map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
async function runDown(): Promise<string[]> {
|
||||
const query = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRunner = { query } as unknown as QueryRunner;
|
||||
const migration = new SellableLootBags1796000000000();
|
||||
await migration.up(queryRunner);
|
||||
const upCount = query.mock.calls.length;
|
||||
await migration.down(queryRunner);
|
||||
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||
}
|
||||
|
||||
describe('SellableLootBags1796000000000', () => {
|
||||
it('adds the bag target and bypass columns', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
expect(joined).toContain('"loot_bag_definition_id" uuid');
|
||||
expect(joined).toContain('"bypass_conditions" jsonb');
|
||||
expect(joined).toContain('FK_shop_offers_loot_bag');
|
||||
});
|
||||
|
||||
it('makes the item target nullable so a bag offer can exist', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL');
|
||||
});
|
||||
|
||||
it('requires exactly one target per offer', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
// An offer that sells nothing, or sells both an item and a bag, is a
|
||||
// content bug the database must not store.
|
||||
expect(joined).toContain('CHK_shop_offers_single_target');
|
||||
expect(joined).toContain('num_nonnulls');
|
||||
});
|
||||
|
||||
it('keeps both target kinds unique per shop via partial indexes', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
// A plain unique index over a nullable column would let a shop hold
|
||||
// unlimited bag offers, because Postgres treats NULLs as distinct.
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL',
|
||||
);
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears content offers so the seed can own stable ids', async () => {
|
||||
const joined = (await runUp()).join('\n');
|
||||
|
||||
expect(joined).toContain('DELETE FROM "shop_offers"');
|
||||
});
|
||||
|
||||
it('drops what it added and restores the original index on rollback', async () => {
|
||||
const joined = (await runDown()).join('\n');
|
||||
|
||||
expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"');
|
||||
expect(joined).toContain('DROP COLUMN "bypass_conditions"');
|
||||
expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL');
|
||||
expect(joined).toContain(
|
||||
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice 0.8.5 entity schema', () => {
|
||||
function column(target: unknown, propertyName: string) {
|
||||
return getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === target && candidate.propertyName === propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
it('lets the item target sit empty when the offer sells a bag instead', () => {
|
||||
expect(column(ShopOffer, 'itemDefinitionId')?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('gives a bag offer its own nullable uuid target', () => {
|
||||
const lootBagDefinitionId = column(ShopOffer, 'lootBagDefinitionId');
|
||||
expect(lootBagDefinitionId?.options.type).toBe('uuid');
|
||||
expect(lootBagDefinitionId?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('requires every offer to carry its bypass conditions, empty or not', () => {
|
||||
const bypassConditions = column(ShopOffer, 'bypassConditions');
|
||||
expect(bypassConditions?.options.type).toBe('jsonb');
|
||||
expect(bypassConditions?.options.nullable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('declares both partial unique indexes with their WHERE clauses', () => {
|
||||
const indices = getMetadataArgsStorage().indices.filter(
|
||||
(candidate) => candidate.target === ShopOffer,
|
||||
);
|
||||
|
||||
const itemIndex = indices.find(
|
||||
(candidate) => candidate.name === 'IDX_shop_offers_shop_item',
|
||||
);
|
||||
const bagIndex = indices.find(
|
||||
(candidate) => candidate.name === 'IDX_shop_offers_shop_bag',
|
||||
);
|
||||
|
||||
expect(itemIndex?.unique).toBe(true);
|
||||
expect(itemIndex?.where).toBe('"item_definition_id" IS NOT NULL');
|
||||
|
||||
expect(bagIndex?.unique).toBe(true);
|
||||
expect(bagIndex?.where).toBe('"loot_bag_definition_id" IS NOT NULL');
|
||||
});
|
||||
});
|
||||
293
apps/api/src/database/seeds/item-content.ts
Normal file
293
apps/api/src/database/seeds/item-content.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemRarity } from '../../items/item-rarity.enum';
|
||||
import { ItemType } from '../../items/item-type.enum';
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ItemKey,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
|
||||
export interface SeedItemDefinition {
|
||||
id: string;
|
||||
key: ItemKey;
|
||||
name: string;
|
||||
description: string;
|
||||
type: ItemType;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
rarity: ItemRarity;
|
||||
lootCategory: LootCategory | null;
|
||||
tier: 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'
|
||||
>
|
||||
> = {},
|
||||
// Only trade goods carry one; everything else is uncapped (0.7.5 §4, §8).
|
||||
lootCategory: LootCategory | null = null,
|
||||
): SeedItemDefinition {
|
||||
return {
|
||||
id: ITEM_IDS[key],
|
||||
key,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
equipmentSlot,
|
||||
rarity,
|
||||
lootCategory,
|
||||
tier: 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',
|
||||
'Worn Shortsword',
|
||||
"A recruit's blade, sharpened more often than it's been swung.",
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 8 },
|
||||
),
|
||||
item(
|
||||
'bandit-blade',
|
||||
'Bandit Blade',
|
||||
'A roughly serrated blade, forged for quick raids.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.COMMON,
|
||||
{ weaponDamage: 11, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'ash-blade',
|
||||
'Ashen Blade',
|
||||
'Tempered in the embers of the Ashen Fields; the edge still glows faintly.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.WEAPON,
|
||||
ItemRarity.RARE,
|
||||
{ weaponDamage: 15, bonusAttack: 2 },
|
||||
),
|
||||
item(
|
||||
'bandit-hood',
|
||||
'Bandit Hood',
|
||||
"Scarred leather that hides both the wearer's face and their intent.",
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.HEAD,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'reinforced-leather-jacket',
|
||||
'Reinforced Leather Jacket',
|
||||
'Leather sewn with iron plates, heavy and dependable.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.CHEST,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 7, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'raider-gloves',
|
||||
'Plunderer Gloves',
|
||||
"Studded gloves, worn smooth from handling other people's belongings.",
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.HANDS,
|
||||
ItemRarity.COMMON,
|
||||
{ bonusArmor: 3, bonusAttack: 1 },
|
||||
),
|
||||
item(
|
||||
'guardsman-legs',
|
||||
"Watchman's Leggings",
|
||||
'Leg armor of the Border Watch, patched at the knees.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.LEGS,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 5, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'ash-boots',
|
||||
'Ashen Boots',
|
||||
'Boots that walked through smoldering fields and never quite left them behind.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.FEET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusArmor: 4, bonusHp: 5 },
|
||||
),
|
||||
item(
|
||||
'borderwatch-sigil',
|
||||
'Mark of the Border Watch',
|
||||
'The crest of a watchtower that no longer stands.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.RARE,
|
||||
{ bonusAttack: 3, bonusHp: 10 },
|
||||
),
|
||||
item(
|
||||
'burned-captain-pendant',
|
||||
"Charred Captain's Pendant",
|
||||
'A skull cast in slag, its embers never quite gone out.',
|
||||
ItemType.EQUIPMENT,
|
||||
EquipmentSlot.AMULET,
|
||||
ItemRarity.EPIC,
|
||||
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
|
||||
),
|
||||
// Seeded as content only. Slice 0.4 implements no consumable use, and the
|
||||
// Road Bandit loot entry for it is deliberately deferred (spec §16).
|
||||
item(
|
||||
'small-healing-potion',
|
||||
'Small Healing Potion',
|
||||
'A bitter draught that makes wounds forgettable, if only for a breath.',
|
||||
ItemType.CONSUMABLE,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
),
|
||||
// Trade Goods (spec §17): turned in to the Border Watch for Silver and
|
||||
// reputation rather than crafted with. Playable Slice 0.7 V2 §5 makes one
|
||||
// of these the guaranteed drop of every Burned Road enemy, which is why
|
||||
// there are now four of them -- one per monster.
|
||||
item(
|
||||
'ash-pelt',
|
||||
'Ashen Pelt',
|
||||
'Singed pelt, tough as leather and grey with drifting ash.',
|
||||
ItemType.TRADE_GOOD,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.HIDE,
|
||||
),
|
||||
item(
|
||||
'tough-hide',
|
||||
'Tough Hide',
|
||||
'Road-hound hide, scarred over so often it barely takes a blade.',
|
||||
ItemType.TRADE_GOOD,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.HIDE,
|
||||
),
|
||||
item(
|
||||
'bandit-insignia',
|
||||
'Raider Insignia',
|
||||
'A roughly stamped token marking a road bandit as one of their band.',
|
||||
ItemType.TROPHY,
|
||||
null,
|
||||
ItemRarity.COMMON,
|
||||
{},
|
||||
LootCategory.RAIDER_TROPHY,
|
||||
),
|
||||
item(
|
||||
'charred-raider-insignia',
|
||||
'Charred Raider Insignia',
|
||||
'The same stamped token, warped by heat until the band mark is barely legible.',
|
||||
ItemType.TROPHY,
|
||||
null,
|
||||
ItemRarity.RARE,
|
||||
{},
|
||||
LootCategory.RAIDER_TROPHY,
|
||||
),
|
||||
];
|
||||
|
||||
export const LOOT_TABLES = [
|
||||
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Ash Rat Loot' },
|
||||
{
|
||||
id: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
key: 'road-bandit-loot',
|
||||
name: 'Road Bandit Loot',
|
||||
},
|
||||
{
|
||||
id: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
key: 'wild-road-dog-loot',
|
||||
name: 'Feral Road Hound Loot',
|
||||
},
|
||||
{
|
||||
id: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
key: 'charred-looter-loot',
|
||||
name: 'Charred Raider Loot',
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Equipment drop chances come from
|
||||
* docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28; the trade goods
|
||||
* are guaranteed by Playable Slice 0.7 V2 §5. Every entry is an independent
|
||||
* roll (spec §17), rolled in `position` order, so the guaranteed trade good
|
||||
* and the equipment chance never influence each other.
|
||||
*
|
||||
* `position` values of pre-existing entries are left where they were: the
|
||||
* seed upserts on (lootTableId, itemDefinitionId) while `position` carries a
|
||||
* unique index, so renumbering an existing table in one statement would
|
||||
* collide with itself.
|
||||
*
|
||||
* DEFERRED: the Feral Road Hound is also meant to drop a Small Healing Potion
|
||||
* "if potions are already lootable" (spec §6). They are not — combat potions
|
||||
* are still a fixed per-fight count, not inventory-backed — so the entry waits
|
||||
* rather than seeding an item that does nothing.
|
||||
*/
|
||||
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'),
|
||||
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.6000'),
|
||||
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '0.6000'),
|
||||
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'),
|
||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'),
|
||||
];
|
||||
29
apps/api/src/database/seeds/item.constants.ts
Normal file
29
apps/api/src/database/seeds/item.constants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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',
|
||||
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
|
||||
'tough-hide': '50000000-0000-4000-8000-00000000000e',
|
||||
'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f',
|
||||
} as const;
|
||||
|
||||
export type ItemKey = keyof typeof ITEM_IDS;
|
||||
|
||||
// One table per monster (Playable Slice 0.7 V2 spec §5): each Burned Road
|
||||
// enemy now has its own guaranteed trade good, so they can no longer share.
|
||||
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
|
||||
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
|
||||
export const WILD_ROAD_DOG_LOOT_TABLE_ID =
|
||||
'60000000-0000-4000-8000-000000000003';
|
||||
export const CHARRED_LOOTER_LOOT_TABLE_ID =
|
||||
'60000000-0000-4000-8000-000000000004';
|
||||
241
apps/api/src/database/seeds/local-location.content.ts
Normal file
241
apps/api/src/database/seeds/local-location.content.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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: 'Ashen Fields',
|
||||
regionTierLabel: 'Tier 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localDescription:
|
||||
'An old trade road, burned to ash by fire and war. Charred carts, broken weapons, and silenced cries line the path into the Ashen Fields.',
|
||||
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: 'Hunting Ground',
|
||||
actionLabel: 'Begin Hunt',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
xPercent: 57,
|
||||
yPercent: 33,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'inspect-tracks',
|
||||
title: 'Suspicious Tracks',
|
||||
actionLabel: 'Investigate',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 40,
|
||||
yPercent: 82,
|
||||
enabled: true,
|
||||
resultTitle: 'Suspicious Tracks',
|
||||
resultText:
|
||||
'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
|
||||
},
|
||||
{
|
||||
key: 'search-abandoned-wagon',
|
||||
title: 'Abandoned Wagon',
|
||||
actionLabel: 'Search',
|
||||
type: 'SEARCH',
|
||||
iconKey: 'search',
|
||||
xPercent: 86,
|
||||
yPercent: 70,
|
||||
enabled: true,
|
||||
resultTitle: 'Abandoned Wagon',
|
||||
resultText:
|
||||
'The wagon has been thoroughly looted. Among the charred planks you find only empty crates and signs of a hasty departure.',
|
||||
},
|
||||
{
|
||||
key: 'wounded-scout',
|
||||
title: 'Wounded Scout',
|
||||
actionLabel: 'Talk',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 17,
|
||||
yPercent: 62,
|
||||
enabled: true,
|
||||
resultTitle: 'Wounded Scout',
|
||||
resultText:
|
||||
'"The road isn\'t safe anymore. The raiders are coming from the direction of the old watchpost. If you keep going, keep your eyes open."',
|
||||
},
|
||||
],
|
||||
localPrimaryActions: [
|
||||
{
|
||||
key: 'start-hunt',
|
||||
label: 'Begin Hunt',
|
||||
description: 'Hunt in this area',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'investigate-tracks',
|
||||
label: 'Investigate tracks',
|
||||
description: 'Find clues',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'inspect-tracks',
|
||||
},
|
||||
{
|
||||
key: 'search-surroundings',
|
||||
label: 'Search surroundings',
|
||||
description: 'Find loot',
|
||||
type: 'SEARCH',
|
||||
iconKey: 'search',
|
||||
enabled: true,
|
||||
poiKey: 'search-abandoned-wagon',
|
||||
},
|
||||
{
|
||||
key: 'open-map',
|
||||
label: 'To Map',
|
||||
description: 'Change area',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
// Only categories the loot tables on this road actually back: 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").
|
||||
//
|
||||
// Silver and experience were both dropped from this preview by slice 0.6.5,
|
||||
// and Playable Slice 0.7 V2 §7 makes that permanent: a normal kill grants
|
||||
// no XP, Silver, regional reputation or World Renown, and the monster
|
||||
// definitions no longer carry a currency range at all. Silver reaches the
|
||||
// player through turn-ins instead. Leaving either entry in place would
|
||||
// break this list's own rule.
|
||||
localRewardPreview: [
|
||||
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
|
||||
{ key: 'material', label: 'Trade Goods', iconKey: 'material' },
|
||||
],
|
||||
};
|
||||
|
||||
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||
regionName: 'Ashen Fields',
|
||||
regionTierLabel: 'Tier 1',
|
||||
locationType: 'TRANSITION',
|
||||
localDescription:
|
||||
"At the black South Gate, Graufurt's protection ends. Beyond the watch-fires begins the silent expanse of the Ashen Fields.",
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
localPointsOfInterest: [
|
||||
{
|
||||
key: 'gate-notice',
|
||||
title: 'Notice Board',
|
||||
actionLabel: 'Read',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 22,
|
||||
yPercent: 42,
|
||||
enabled: true,
|
||||
resultTitle: 'Notice Board',
|
||||
resultText:
|
||||
'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.',
|
||||
resultImg: '/images/environment/notice-board.png',
|
||||
},
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Gate Watch',
|
||||
actionLabel: 'Talk',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
resultTitle: 'Gate Watch',
|
||||
resultText:
|
||||
'"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."',
|
||||
resultImg: '/images/npcs/graufurt-gate-watch.png',
|
||||
},
|
||||
// Borin stands at the gate rather than deeper in a town that does not
|
||||
// exist yet as a location. Carries `npcKey` instead of result text, so
|
||||
// this hotspot opens his screen (Playable Slice 0.8 §2).
|
||||
{
|
||||
key: 'borin',
|
||||
title: 'Borin, Quartermaster',
|
||||
actionLabel: 'Trade',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 30,
|
||||
yPercent: 66,
|
||||
enabled: true,
|
||||
npcKey: 'borin-quartermaster',
|
||||
},
|
||||
{
|
||||
key: 'south-road',
|
||||
title: 'Road South',
|
||||
actionLabel: 'To Map',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
xPercent: 66,
|
||||
yPercent: 72,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
localPrimaryActions: [
|
||||
{
|
||||
key: 'talk-to-watch',
|
||||
label: 'Talk to the watch',
|
||||
description: 'Ask about the situation',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
enabled: true,
|
||||
poiKey: 'gate-watch',
|
||||
},
|
||||
{
|
||||
key: 'trade-with-borin',
|
||||
label: 'Trade with Borin',
|
||||
description: 'Sell goods, buy supplies',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
enabled: true,
|
||||
poiKey: 'borin',
|
||||
npcKey: 'borin-quartermaster',
|
||||
},
|
||||
{
|
||||
key: 'read-notice',
|
||||
label: 'Read the notice',
|
||||
description: 'Find clues',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'gate-notice',
|
||||
},
|
||||
{
|
||||
key: 'open-map',
|
||||
label: 'To Map',
|
||||
description: 'Change area',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
localRewardPreview: [],
|
||||
};
|
||||
38
apps/api/src/database/seeds/loot-bag-content.ts
Normal file
38
apps/api/src/database/seeds/loot-bag-content.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
|
||||
export const BASIC_HIDE_BAG_ID = 'a0000000-0000-4000-8000-000000000001';
|
||||
export const BASIC_TROPHY_POUCH_ID = 'a0000000-0000-4000-8000-000000000002';
|
||||
|
||||
export interface SeedLootBagDefinition {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
lootCategory: LootCategory;
|
||||
capacity: number;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The starter bag per category (Playable Slice 0.7.5 §7).
|
||||
*
|
||||
* Capacity 5 against a bagless default of 1: enough that a hunt is worth
|
||||
* making, small enough that the trip still ends.
|
||||
*/
|
||||
export const LOOT_BAG_DEFINITIONS: SeedLootBagDefinition[] = [
|
||||
{
|
||||
id: BASIC_HIDE_BAG_ID,
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
lootCategory: LootCategory.HIDE,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-hide-bag.png',
|
||||
},
|
||||
{
|
||||
id: BASIC_TROPHY_POUCH_ID,
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||
},
|
||||
];
|
||||
440
apps/api/src/database/seeds/npc-content.ts
Normal file
440
apps/api/src/database/seeds/npc-content.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameCondition,
|
||||
GameConditionType,
|
||||
} from '../../conditions/game-condition.types';
|
||||
import { NpcCapability } from '../../npcs/npc.types';
|
||||
import type {
|
||||
DialogueAction,
|
||||
DialogueResponseContent,
|
||||
} from '../../npcs/npc.types';
|
||||
import { ITEM_IDS } from './item.constants';
|
||||
import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content';
|
||||
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
|
||||
|
||||
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
|
||||
export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
|
||||
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
|
||||
|
||||
export const BORIN_KEY = 'borin-quartermaster';
|
||||
export const BORIN_SHOP_KEY = 'borin-supplies';
|
||||
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
|
||||
|
||||
// Stable offer ids so re-seeding re-tunes a price instead of inserting a
|
||||
// second row (AGENTS.md §8). Needed here in particular because an offer has
|
||||
// two possible targets and no single natural key across both shapes.
|
||||
export const BORIN_OFFER_IDS = {
|
||||
potion: 'b4000000-0000-4000-8000-000000000001',
|
||||
shortsword: 'b4000000-0000-4000-8000-000000000002',
|
||||
trophyPouch: 'b4000000-0000-4000-8000-000000000003',
|
||||
hideBag: 'b4000000-0000-4000-8000-000000000004',
|
||||
banditBlade: 'b4000000-0000-4000-8000-000000000005',
|
||||
} as const;
|
||||
|
||||
/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */
|
||||
export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden';
|
||||
|
||||
/**
|
||||
* The Renown 2 milestone (Playable Slice 0.6.5 §6).
|
||||
*
|
||||
* 0.6.5 names "first meaningful trophy returned" as what should move a
|
||||
* character from Renown 1 to 2, and this is the first slice where returning a
|
||||
* trophy is possible at all. Non-repeatable: it fires on the first trade and
|
||||
* never again, which is how routine trading keeps paying silver and reputation
|
||||
* without inflating a power rank (0.6.5 §2.2).
|
||||
*/
|
||||
export const FIRST_TRADE_MILESTONE_KEY = 'first-goods-returned';
|
||||
export const FIRST_TRADE_MILESTONE_ID = 'b3000000-0000-4000-8000-000000000001';
|
||||
|
||||
export interface SeedRenownMilestone {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
renownReward: number;
|
||||
repeatable: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const RENOWN_MILESTONES: SeedRenownMilestone[] = [
|
||||
{
|
||||
id: FIRST_TRADE_MILESTONE_ID,
|
||||
key: FIRST_TRADE_MILESTONE_KEY,
|
||||
name: 'Something Worth Bringing Back',
|
||||
description:
|
||||
'You returned from the Ashen Fields with goods worth trading, and the Border Watch noticed.',
|
||||
renownReward: 1,
|
||||
repeatable: false,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcDefinition {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
locationKey: string;
|
||||
factionKey: string | null;
|
||||
portraitPath: string;
|
||||
artworkPath: string | null;
|
||||
capabilities: NpcCapability[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borin, the Graufurt quartermaster (Playable Slice 0.8 §2).
|
||||
*
|
||||
* Placed at `south-gate` because that is Graufurt in the current content --
|
||||
* the only safe, non-hunting location, named "Graufurt South Gate". No new
|
||||
* town location is invented for him; when Graufurt proper is built out he
|
||||
* moves by changing this one key.
|
||||
*
|
||||
* His capabilities list MERCHANT and RESOURCE_EXCHANGE together, which is the
|
||||
* whole point of the composition model: one person, two functions, no
|
||||
* `MerchantNpc` subclass (NPC spec §2, §26).
|
||||
*/
|
||||
export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
|
||||
{
|
||||
id: BORIN_NPC_ID,
|
||||
key: BORIN_KEY,
|
||||
name: 'Borin',
|
||||
title: 'Quartermaster of the Border Watch',
|
||||
description:
|
||||
'A broad, grey-bearded man who has outlasted three captains and every fashion of optimism. He weighs what you bring him without ceremony, and pays what it is worth.',
|
||||
locationKey: 'south-gate',
|
||||
factionKey: 'border-guard',
|
||||
portraitPath: '/images/npcs/borin.png',
|
||||
artworkPath: null,
|
||||
capabilities: [
|
||||
NpcCapability.DIALOGUE,
|
||||
NpcCapability.MERCHANT,
|
||||
NpcCapability.RESOURCE_EXCHANGE,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedDialogueNode {
|
||||
npcId: string;
|
||||
key: string;
|
||||
text: string;
|
||||
priority: number;
|
||||
conditions: GameCondition[];
|
||||
actions: DialogueAction[];
|
||||
responses: DialogueResponseContent[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borin's lines, chosen by priority (NPC spec §11).
|
||||
*
|
||||
* Three nodes, deliberately overlapping: the greeting outranks the standard
|
||||
* line but only survives the first visit, and the reputation line outranks
|
||||
* both once the Border Watch actually knows the player. Nothing in
|
||||
* `NpcService` knows any of this -- it takes the highest node whose conditions
|
||||
* hold.
|
||||
*/
|
||||
export const DIALOGUE_NODES: SeedDialogueNode[] = [
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-first-meeting',
|
||||
text: 'You have the look of someone who has been south of the gate. Most who go out there come back with nothing but ash in their boots. If you did better than that, I will weigh it and pay you fairly. Not generously. Fairly.',
|
||||
priority: 800,
|
||||
// Fires only before the visit is recorded -- `NpcService` sets `met` after
|
||||
// dialogue is resolved, so this node is unreachable from the second visit.
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'met',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-trusted',
|
||||
text: 'Back again, and still walking. The Watch has started using your name without spitting afterward — that is as close to praise as we get here. Show me what you brought.',
|
||||
priority: 500,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
npcId: BORIN_NPC_ID,
|
||||
key: 'borin-default',
|
||||
text: 'Pelts, hides, raider trinkets — I take all of it, and the Watch asks no questions about where it came from. Silver for goods, and a word in the right ear if the goods are good.',
|
||||
priority: 100,
|
||||
conditions: [],
|
||||
actions: [],
|
||||
responses: [],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcShop {
|
||||
id: string;
|
||||
key: string;
|
||||
npcId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const NPC_SHOPS: SeedNpcShop[] = [
|
||||
{
|
||||
id: BORIN_SHOP_ID,
|
||||
key: BORIN_SHOP_KEY,
|
||||
npcId: BORIN_NPC_ID,
|
||||
name: "Quartermaster's Supplies",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedShopOffer {
|
||||
id: string;
|
||||
shopId: string;
|
||||
itemDefinitionId: string | null;
|
||||
lootBagDefinitionId: string | null;
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
repeatable: boolean;
|
||||
sortOrder: number;
|
||||
conditions: GameCondition[];
|
||||
bypassConditions: GameCondition[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
|
||||
*
|
||||
* Two open offers so Silver always has somewhere to go, and three gated ones
|
||||
* so reputation visibly changes what the player can do (0.8.5 §1). The locked
|
||||
* offers stay listed rather than hidden: a reward you can see is a goal, and a
|
||||
* reward you cannot see is nothing (0.8.5 §5).
|
||||
*
|
||||
* Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation
|
||||
* per trade good, so 25 is three or four good hunts away -- close enough to
|
||||
* pull, far enough to matter -- and 40 is deliberately further out, which is
|
||||
* what makes the Slice 0.9 referral read as a favour rather than a shortcut
|
||||
* around nothing.
|
||||
*
|
||||
* The Bandit Blade sits behind World Renown 3, which today's content cannot
|
||||
* reach: there is exactly one renown milestone, worth +1. That is intentional
|
||||
* and not a balancing oversight -- it is the long-horizon goal on the shelf
|
||||
* until Slice 0.11 adds the milestones that reach it.
|
||||
*/
|
||||
export const SHOP_OFFERS: SeedShopOffer[] = [
|
||||
{
|
||||
id: BORIN_OFFER_IDS.potion,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['small-healing-potion'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: BORIN_OFFER_IDS.shortsword,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 30,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 2,
|
||||
conditions: [],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
// The first gate a player meets, and the one 0.8.5 §5 uses as its worked
|
||||
// example: 40 Silver, Ashen Fields reputation 25.
|
||||
id: BORIN_OFFER_IDS.trophyPouch,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: BASIC_TROPHY_POUCH_ID,
|
||||
currencyType: 'SILVER',
|
||||
price: 40,
|
||||
quantity: 1,
|
||||
repeatable: false,
|
||||
sortOrder: 3,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
// Reputation 40 is out of reach for a new character on purpose. Slice 0.9
|
||||
// sends the player here with the warden's word instead, which is the one
|
||||
// exception the offer system supports (0.8.5 §7).
|
||||
id: BORIN_OFFER_IDS.hideBag,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
currencyType: 'SILVER',
|
||||
price: 35,
|
||||
quantity: 1,
|
||||
repeatable: false,
|
||||
sortOrder: 4,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 40,
|
||||
},
|
||||
],
|
||||
bypassConditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: SOUTH_GATE_REFERRAL_FLAG,
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: BORIN_OFFER_IDS.banditBlade,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 60,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 5,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedNpcExchangeProfile {
|
||||
id: string;
|
||||
key: string;
|
||||
npcId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const NPC_EXCHANGE_PROFILES: SeedNpcExchangeProfile[] = [
|
||||
{
|
||||
id: BORIN_EXCHANGE_PROFILE_ID,
|
||||
key: BORIN_EXCHANGE_KEY,
|
||||
npcId: BORIN_NPC_ID,
|
||||
name: 'Border Watch Trade-In',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeedExchangeRule {
|
||||
profileId: string;
|
||||
inputItemId: string;
|
||||
inputQuantity: number;
|
||||
factionId: string;
|
||||
silverReward: number;
|
||||
regionReputationReward: number;
|
||||
renownMilestoneKey: string | null;
|
||||
conditions: GameCondition[];
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Borin pays (Playable Slice 0.8 §4, §5).
|
||||
*
|
||||
* All four Burned Road trade goods are accepted, and the rare Charred Raider
|
||||
* Insignia is worth visibly more than the common Ashen Pelt -- 30 silver
|
||||
* against 5, six times the value for a drop that comes off a 2%-weight
|
||||
* encounter (slice §5).
|
||||
*
|
||||
* Silver is up from the old turn-in values (4 and 12) because this is now the
|
||||
* only way to earn it: normal kills pay nothing at all (slice 0.7 §7), so the
|
||||
* exchange has to carry the whole economy on its own (slice §7).
|
||||
*
|
||||
* Every rule pays Border Watch reputation, the Ashen Fields faction. Only the
|
||||
* pelt carries the renown milestone: it is the guaranteed Ash Rat drop, so
|
||||
* the very first trade any player makes will fire it, whatever they hunted.
|
||||
* Provisional balancing values (slice §4).
|
||||
*/
|
||||
export const EXCHANGE_RULES: SeedExchangeRule[] = [
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['ash-pelt'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 5,
|
||||
regionReputationReward: 2,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['tough-hide'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 8,
|
||||
regionReputationReward: 3,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 2,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['bandit-insignia'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 14,
|
||||
regionReputationReward: 5,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 3,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||||
inputItemId: ITEM_IDS['charred-raider-insignia'],
|
||||
inputQuantity: 1,
|
||||
factionId: BORDER_GUARD_FACTION_ID,
|
||||
silverReward: 30,
|
||||
regionReputationReward: 12,
|
||||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||||
conditions: [],
|
||||
sortOrder: 4,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
25
apps/api/src/database/seeds/reputation-content.ts
Normal file
25
apps/api/src/database/seeds/reputation-content.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const BORDER_GUARD_FACTION_ID = '80000000-0000-4000-8000-000000000001';
|
||||
|
||||
export interface SeedReputationFaction {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
regionKey: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Only the Border Watch (Grenzwacht in the spec) is seeded and usable in
|
||||
// Slice 0.6.5 (spec §8). Dämmerjäger ('dusk-hunters') and Letzte Wacht
|
||||
// ('last-watch') are future-region keys, intentionally not seeded yet.
|
||||
export const REPUTATION_FACTIONS: SeedReputationFaction[] = [
|
||||
{
|
||||
id: BORDER_GUARD_FACTION_ID,
|
||||
key: 'border-guard',
|
||||
name: 'Border Watch',
|
||||
description:
|
||||
'The last organized watch of the Ashen Fields, guarding what remains of the trade routes and outposts.',
|
||||
regionKey: 'ashen-fields',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -2,3 +2,5 @@ export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||
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,9 +1,38 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
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 { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
||||
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
|
||||
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
|
||||
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
import { NpcShop } from '../../shops/entities/npc-shop.entity';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameConditionType,
|
||||
} from '../../conditions/game-condition.types';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
|
||||
import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -13,6 +42,8 @@ const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||
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 {
|
||||
readonly rows: Row[] = [];
|
||||
@@ -67,24 +98,45 @@ function createDataSource(
|
||||
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(),
|
||||
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === LocationDefinition) {
|
||||
return locationRepository;
|
||||
}
|
||||
if (entity === LocationConnection) {
|
||||
return connectionRepository;
|
||||
}
|
||||
if (entity === Character) {
|
||||
return characterRepository;
|
||||
}
|
||||
if (entity === MonsterDefinition) {
|
||||
return monsterRepository;
|
||||
}
|
||||
if (entity === LocationMonster) {
|
||||
return locationMonsterRepository;
|
||||
}
|
||||
if (entity === LocationDefinition) return locationRepository;
|
||||
if (entity === LocationConnection) return connectionRepository;
|
||||
if (entity === Character) return characterRepository;
|
||||
if (entity === MonsterDefinition) return monsterRepository;
|
||||
if (entity === LocationMonster) return locationMonsterRepository;
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
if (entity === CharacterItem) return characterItemRepository;
|
||||
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||
if (entity === ReputationFaction) return reputationFactionRepository;
|
||||
if (entity === RenownMilestoneDefinition)
|
||||
return renownMilestoneRepository;
|
||||
if (entity === NpcDefinition) return npcDefinitionRepository;
|
||||
if (entity === DialogueNode) return dialogueNodeRepository;
|
||||
if (entity === NpcShop) return npcShopRepository;
|
||||
if (entity === ShopOffer) return shopOfferRepository;
|
||||
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
|
||||
if (entity === ExchangeRule) return exchangeRuleRepository;
|
||||
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
|
||||
if (entity === CharacterLootBag) return characterLootBagRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -110,7 +162,10 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
Object.assign(characterRepository.rows[0], {
|
||||
currentLocationId: BURNED_ROAD_ID,
|
||||
currentHp: 57,
|
||||
experience: 39,
|
||||
// Deliberately NOT the seed default of 1: if a re-seed clobbered player
|
||||
// state back to defaults, an assertion on the default value could not
|
||||
// tell the difference.
|
||||
renown: 5,
|
||||
});
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
@@ -149,57 +204,194 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect.objectContaining({
|
||||
currentLocationId: BURNED_ROAD_ID,
|
||||
currentHp: 57,
|
||||
experience: 39,
|
||||
renown: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
|
||||
expect(monsterRepository.rows).toHaveLength(2);
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||
expect(monsterRepository.rows).toHaveLength(4);
|
||||
expect(monsterRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
name: 'Ash Rat',
|
||||
monsterCategory: 'BEAST',
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
attack: 5,
|
||||
armor: 0,
|
||||
experienceReward: 8,
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
// Pure baseline combat: no telegraph, no status effect (spec §4).
|
||||
abilities: {},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'road-bandit',
|
||||
name: 'Straßenräuber',
|
||||
name: 'Road Bandit',
|
||||
monsterCategory: 'HUMANOID',
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
experienceReward: 16,
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
abilities: { telegraph: { roundInterval: 3, damageMultiplier: 1.6 } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'wild-road-dog',
|
||||
name: 'Feral Road Hound',
|
||||
monsterCategory: 'BEAST',
|
||||
level: 1,
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||
abilities: {
|
||||
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-looter',
|
||||
name: 'Charred Raider',
|
||||
monsterCategory: 'HUMANOID',
|
||||
level: 2,
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||
// The rare encounter reuses the known telegraph on a tighter
|
||||
// cadence rather than introducing a new subsystem (spec §4).
|
||||
abilities: { telegraph: { roundInterval: 2, damageMultiplier: 1.6 } },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Every monster carries an atmosphere line for its encounter card
|
||||
// (spec §9).
|
||||
for (const row of monsterRepository.rows) {
|
||||
expect(typeof row.flavorText).toBe('string');
|
||||
}
|
||||
|
||||
// No monster carries a currency range any more: a normal kill grants no
|
||||
// Silver (spec §7).
|
||||
for (const row of monsterRepository.rows) {
|
||||
expect(row).not.toHaveProperty('silverMin');
|
||||
expect(row).not.toHaveProperty('silverMax');
|
||||
}
|
||||
|
||||
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ASH_RAT_MONSTER_ID,
|
||||
weight: 70,
|
||||
weight: 50,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: WILD_ROAD_DOG_MONSTER_ID,
|
||||
weight: 30,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ROAD_BANDIT_MONSTER_ID,
|
||||
weight: 30,
|
||||
weight: 18,
|
||||
encounterType: 'NORMAL',
|
||||
}),
|
||||
// The rare find, at 2 out of 100 (spec §3).
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: CHARRED_LOOTER_MONSTER_ID,
|
||||
weight: 2,
|
||||
encounterType: 'RARE',
|
||||
}),
|
||||
]),
|
||||
['locationId', 'monsterId'],
|
||||
);
|
||||
expect(locationMonsterRepository.rows).toHaveLength(2);
|
||||
expect(locationMonsterRepository.rows).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('seeds the local view content of the Burned Road 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: 'Ashen Fields',
|
||||
regionTierLabel: 'Tier 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
}),
|
||||
);
|
||||
expect(burnedRoad.localDescription).toContain(
|
||||
'An old trade road, burned to ash by fire and war.',
|
||||
);
|
||||
|
||||
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([
|
||||
'Begin Hunt',
|
||||
'Investigate tracks',
|
||||
'Search surroundings',
|
||||
'To Map',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the South Gate 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',
|
||||
}),
|
||||
);
|
||||
// Four since Slice 0.8 gave Borin a hotspot at the gate.
|
||||
expect(southGate.localPointsOfInterest).toHaveLength(4);
|
||||
// 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 () => {
|
||||
@@ -212,7 +404,7 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
locationRepository.rows.push({
|
||||
id: persistedSouthGateId,
|
||||
key: 'south-gate',
|
||||
name: 'Veraltetes Südtor',
|
||||
name: 'Outdated South Gate',
|
||||
});
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
@@ -229,7 +421,7 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect.objectContaining({
|
||||
id: persistedSouthGateId,
|
||||
key: 'south-gate',
|
||||
name: 'Südtor von Graufurt',
|
||||
name: 'Graufurt South Gate',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: BURNED_ROAD_ID,
|
||||
@@ -256,4 +448,609 @@ 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(15);
|
||||
expect(itemRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'bandit-blade',
|
||||
name: 'Bandit Blade',
|
||||
type: 'EQUIPMENT',
|
||||
equipmentSlot: 'WEAPON',
|
||||
rarity: 'COMMON',
|
||||
weaponDamage: 11,
|
||||
bonusAttack: 1,
|
||||
sellPrice: 0,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'ash-pelt',
|
||||
name: 'Ashen Pelt',
|
||||
type: 'TRADE_GOOD',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'HIDE',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'tough-hide',
|
||||
name: 'Tough Hide',
|
||||
type: 'TRADE_GOOD',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'HIDE',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'bandit-insignia',
|
||||
name: 'Raider Insignia',
|
||||
type: 'TROPHY',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-raider-insignia',
|
||||
name: 'Charred Raider Insignia',
|
||||
type: 'TROPHY',
|
||||
equipmentSlot: null,
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Equipment and consumables stay outside the carrying system entirely
|
||||
// (0.7.5 §8), so they must not pick up a category by accident.
|
||||
for (const row of itemRepository.rows) {
|
||||
if (row.type === 'EQUIPMENT' || row.type === 'CONSUMABLE') {
|
||||
expect(row.lootCategory).toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// One table per monster now that each has its own guaranteed trade good.
|
||||
expect(lootTableRepository.rows).toHaveLength(4);
|
||||
expect(lootEntryRepository.rows).toHaveLength(12);
|
||||
|
||||
// Every Burned Road enemy guarantees exactly one trade good (spec §5).
|
||||
const guaranteedTradeGoods: Array<[string, string]> = [
|
||||
[ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt']],
|
||||
[WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide']],
|
||||
[ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia']],
|
||||
[CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS['charred-raider-insignia']],
|
||||
];
|
||||
for (const [lootTableId, itemDefinitionId] of guaranteedTradeGoods) {
|
||||
expect(lootEntryRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
lootTableId,
|
||||
itemDefinitionId,
|
||||
dropChance: '1.0000',
|
||||
minQuantity: 1,
|
||||
maxQuantity: 1,
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Equipment stays a roll, so it can never be confused with the
|
||||
// guaranteed good (spec §6).
|
||||
expect(lootEntryRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||
position: 1,
|
||||
dropChance: '0.1800',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['ash-boots'],
|
||||
dropChance: '0.0800',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
itemDefinitionId: ITEM_IDS['reinforced-leather-jacket'],
|
||||
dropChance: '0.1000',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// 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: 'wild-road-dog',
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'road-bandit',
|
||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-looter',
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds both starter loot bags idempotently', async () => {
|
||||
const lootBagDefinitionRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
lootBagDefinitionRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(lootBagDefinitionRepository.rows).toHaveLength(2);
|
||||
expect(lootBagDefinitionRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
lootCategory: 'HIDE',
|
||||
capacity: 5,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: 'RAIDER_TROPHY',
|
||||
capacity: 5,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the demo character only the Hide Bag, idempotently', async () => {
|
||||
const characterLootBagRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterLootBagRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
// Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
|
||||
// so handing it to the demo character for free would undercut the
|
||||
// showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
|
||||
// the bagless default of 1 with no way to raise it before Slice 0.9 grants
|
||||
// it through the warden's referral. See the ASSUMPTION note in the seed.
|
||||
expect(characterLootBagRepository.rows).toHaveLength(1);
|
||||
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
|
||||
true,
|
||||
]);
|
||||
expect(
|
||||
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
|
||||
).toEqual(['a0000000-0000-4000-8000-000000000001']);
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the Border Watch faction', async () => {
|
||||
const reputationFactionRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
reputationFactionRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
const faction = await dataSource
|
||||
.getRepository(ReputationFaction)
|
||||
.findOneBy({ key: 'border-guard' });
|
||||
|
||||
expect(faction).toMatchObject({ name: 'Border Watch', enabled: true });
|
||||
});
|
||||
|
||||
it('seeds Borin with his shop and every trade-in rule', async () => {
|
||||
const npcDefinitionRepository = new InMemoryRepository();
|
||||
const npcShopRepository = new InMemoryRepository();
|
||||
const exchangeRuleRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
npcDefinitionRepository,
|
||||
new InMemoryRepository(),
|
||||
npcShopRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
exchangeRuleRepository,
|
||||
);
|
||||
|
||||
// Twice: seeds must be idempotent (NPC spec §32).
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(npcDefinitionRepository.rows).toHaveLength(1);
|
||||
expect(npcDefinitionRepository.rows[0]).toMatchObject({
|
||||
key: 'borin-quartermaster',
|
||||
enabled: true,
|
||||
});
|
||||
// Placed in Graufurt via the location key, not a hardcoded id.
|
||||
expect(npcDefinitionRepository.rows[0].locationId).toBe(SOUTH_GATE_ID);
|
||||
expect(npcShopRepository.rows).toHaveLength(1);
|
||||
|
||||
// All four Burned Road trade goods are accepted (slice 0.8 §5), and the
|
||||
// rare Charred Raider Insignia pays visibly more than the common pelt.
|
||||
expect(exchangeRuleRepository.rows).toHaveLength(4);
|
||||
const silverByItem = new Map(
|
||||
exchangeRuleRepository.rows.map((row: Row) => [
|
||||
row.inputItemId,
|
||||
row.silverReward,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
silverByItem.get(ITEM_IDS['charred-raider-insignia']),
|
||||
).toBeGreaterThan(silverByItem.get(ITEM_IDS['ash-pelt']));
|
||||
});
|
||||
|
||||
it('seeds the renown milestone the first trade-in completes', async () => {
|
||||
const renownMilestoneRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
renownMilestoneRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
// Non-repeatable, so routine trading cannot pump a power rank
|
||||
// (Slice 0.6.5 §2.2).
|
||||
expect(renownMilestoneRepository.rows).toHaveLength(1);
|
||||
expect(renownMilestoneRepository.rows[0]).toMatchObject({
|
||||
key: 'first-goods-returned',
|
||||
repeatable: false,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds two visibly locked bag offers and one renown-gated weapon', async () => {
|
||||
const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0);
|
||||
|
||||
expect(gated).toHaveLength(3);
|
||||
expect(gated.map((offer) => offer.conditions[0].type).sort()).toEqual([
|
||||
GameConditionType.REGION_REPUTATION,
|
||||
GameConditionType.REGION_REPUTATION,
|
||||
GameConditionType.WORLD_RENOWN,
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the Hide Bag a referral bypass for Slice 0.9', async () => {
|
||||
const hideBag = SHOP_OFFERS.find(
|
||||
(offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID,
|
||||
);
|
||||
|
||||
expect(hideBag?.bypassConditions).toEqual([
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'referred-by-south-gate-warden',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sells every bag as a one-off', async () => {
|
||||
// A bag is one object; a second copy raises no capacity.
|
||||
for (const offer of SHOP_OFFERS.filter(
|
||||
(candidate) => candidate.lootBagDefinitionId !== null,
|
||||
)) {
|
||||
expect(offer.repeatable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every offer exactly one target', async () => {
|
||||
for (const offer of SHOP_OFFERS) {
|
||||
const targets = [
|
||||
offer.itemDefinitionId,
|
||||
offer.lootBagDefinitionId,
|
||||
].filter((target) => target !== null);
|
||||
expect(targets).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every offer a stable id so re-seeding cannot duplicate it', async () => {
|
||||
const ids = SHOP_OFFERS.map((offer) => offer.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.every((id) => id.length === 36)).toBe(true);
|
||||
});
|
||||
|
||||
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
|
||||
const shopOfferRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
shopOfferRepository,
|
||||
);
|
||||
|
||||
// Twice, because that is the whole point of the stable ids (NPC spec §32).
|
||||
// Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict
|
||||
// target of (shopId, itemDefinitionId) would fold them into a single row
|
||||
// here -- and would blow up outright on Postgres, where NULL never equals
|
||||
// NULL in a unique index.
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(shopOfferRepository.rows).toHaveLength(5);
|
||||
expect(
|
||||
shopOfferRepository.rows.map((row: Row) => String(row.id)).sort(),
|
||||
).toEqual(
|
||||
[
|
||||
BORIN_OFFER_IDS.potion,
|
||||
BORIN_OFFER_IDS.shortsword,
|
||||
BORIN_OFFER_IDS.trophyPouch,
|
||||
BORIN_OFFER_IDS.hideBag,
|
||||
BORIN_OFFER_IDS.banditBlade,
|
||||
].sort(),
|
||||
);
|
||||
|
||||
// Prices and thresholds are balancing decisions, not incidentals, so a
|
||||
// refactor must not be able to drift one silently (AGENTS.md §39).
|
||||
const byId = new Map<string, Row>(
|
||||
shopOfferRepository.rows.map((row: Row): [string, Row] => [
|
||||
String(row.id),
|
||||
row,
|
||||
]),
|
||||
);
|
||||
expect(byId.get(BORIN_OFFER_IDS.potion)).toMatchObject({
|
||||
price: 12,
|
||||
conditions: [],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.shortsword)).toMatchObject({
|
||||
price: 30,
|
||||
conditions: [],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.trophyPouch)).toMatchObject({
|
||||
price: 40,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.hideBag)).toMatchObject({
|
||||
price: 35,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 40,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.banditBlade)).toMatchObject({
|
||||
price: 60,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
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 { 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 { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
|
||||
import { MonsterCategory } from '../../monsters/monster-category.enum';
|
||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
||||
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
|
||||
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
|
||||
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
import { NpcShop } from '../../shops/entities/npc-shop.entity';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
import {
|
||||
ITEM_DEFINITIONS,
|
||||
LOOT_TABLES,
|
||||
LOOT_TABLE_ENTRIES,
|
||||
} from './item-content';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import {
|
||||
BURNED_ROAD_LOCAL_CONTENT,
|
||||
SOUTH_GATE_LOCAL_CONTENT,
|
||||
} from './local-location.content';
|
||||
import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
|
||||
import { REPUTATION_FACTIONS } from './reputation-content';
|
||||
import {
|
||||
DIALOGUE_NODES,
|
||||
EXCHANGE_RULES,
|
||||
NPC_DEFINITIONS,
|
||||
NPC_EXCHANGE_PROFILES,
|
||||
NPC_SHOPS,
|
||||
RENOWN_MILESTONES,
|
||||
SHOP_OFFERS,
|
||||
} from './npc-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(
|
||||
@@ -21,14 +71,31 @@ export async function seedVisibleVerticalSlice(
|
||||
const characterRepository = dataSource.getRepository(Character);
|
||||
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
||||
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
||||
const itemRepository = dataSource.getRepository(ItemDefinition);
|
||||
const lootTableRepository = dataSource.getRepository(LootTable);
|
||||
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
||||
const lootBagDefinitionRepository =
|
||||
dataSource.getRepository(LootBagDefinition);
|
||||
const reputationFactionRepository =
|
||||
dataSource.getRepository(ReputationFaction);
|
||||
const renownMilestoneRepository = dataSource.getRepository(
|
||||
RenownMilestoneDefinition,
|
||||
);
|
||||
const npcDefinitionRepository = dataSource.getRepository(NpcDefinition);
|
||||
const dialogueNodeRepository = dataSource.getRepository(DialogueNode);
|
||||
const npcShopRepository = dataSource.getRepository(NpcShop);
|
||||
const shopOfferRepository = dataSource.getRepository(ShopOffer);
|
||||
const npcExchangeProfileRepository =
|
||||
dataSource.getRepository(NpcExchangeProfile);
|
||||
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
|
||||
|
||||
const locations = [
|
||||
{
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
name: 'Südtor von Graufurt',
|
||||
name: 'Graufurt South Gate',
|
||||
description:
|
||||
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
|
||||
"At the black South Gate, Graufurt's protection ends. Beyond the watch-fires begins the silent expanse of the Ashen Fields.",
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
maxRecommendedLevel: 1,
|
||||
@@ -36,13 +103,14 @@ export async function seedVisibleVerticalSlice(
|
||||
isSafe: true,
|
||||
huntingEnabled: false,
|
||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||
...SOUTH_GATE_LOCAL_CONTENT,
|
||||
},
|
||||
{
|
||||
id: BURNED_ROAD_ID,
|
||||
key: 'burned-road',
|
||||
name: 'Verbrannte Straße',
|
||||
name: 'Burned Road',
|
||||
description:
|
||||
'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.',
|
||||
'The old trade road cuts through charred fields. Among the ash and broken wagons, the first dangers wait.',
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
maxRecommendedLevel: 2,
|
||||
@@ -50,17 +118,14 @@ export async function seedVisibleVerticalSlice(
|
||||
isSafe: false,
|
||||
huntingEnabled: true,
|
||||
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) {
|
||||
const existing = await locationRepository.findOneBy({
|
||||
key: location.key,
|
||||
});
|
||||
const existing = await locationRepository.findOneBy({ key: location.key });
|
||||
const { id, key, ...definition } = location;
|
||||
const persistedId = existing?.id ?? id;
|
||||
|
||||
if (existing) {
|
||||
await locationRepository.update(existing.id, definition);
|
||||
@@ -68,13 +133,12 @@ export async function seedVisibleVerticalSlice(
|
||||
await locationRepository.insert(location);
|
||||
}
|
||||
|
||||
if (key === 'south-gate') {
|
||||
southGateId = persistedId;
|
||||
} else {
|
||||
burnedRoadId = persistedId;
|
||||
}
|
||||
locationIds.set(key, existing?.id ?? id);
|
||||
}
|
||||
|
||||
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
||||
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
||||
|
||||
await connectionRepository.upsert(
|
||||
[
|
||||
{
|
||||
@@ -95,43 +159,109 @@ export async function seedVisibleVerticalSlice(
|
||||
['fromLocationId', 'toLocationId'],
|
||||
);
|
||||
|
||||
// Content is upserted by its stable key so re-running never duplicates rows
|
||||
// and never touches player-owned character_items or combat_rewards.
|
||||
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
|
||||
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
|
||||
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
|
||||
'lootTableId',
|
||||
'itemDefinitionId',
|
||||
]);
|
||||
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
|
||||
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
|
||||
await renownMilestoneRepository.upsert(RENOWN_MILESTONES, ['key']);
|
||||
|
||||
// Burned Road roster (Playable Slice 0.7 V2 §2, §4). Each enemy has to be
|
||||
// mechanically and economically distinct, so the differences live in
|
||||
// `abilities` (what it does in a fight) and `lootTableId` (what it is worth)
|
||||
// rather than in engine or UI branches.
|
||||
const monsters = [
|
||||
{
|
||||
id: ASH_RAT_MONSTER_ID,
|
||||
key: 'ash-rat',
|
||||
name: 'Aschenratte',
|
||||
name: 'Ash Rat',
|
||||
monsterCategory: MonsterCategory.BEAST,
|
||||
level: 1,
|
||||
maxHp: 45,
|
||||
attack: 5,
|
||||
armor: 0,
|
||||
experienceReward: 8,
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
|
||||
// Pure baseline combat: no telegraph, no status effect, short fight.
|
||||
abilities: {},
|
||||
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: 'Feral Road Hound',
|
||||
monsterCategory: MonsterCategory.BEAST,
|
||||
level: 1,
|
||||
maxHp: 55,
|
||||
attack: 7,
|
||||
armor: 0,
|
||||
flavorText:
|
||||
'It stopped waiting for scraps a long time ago. Its bites do not close.',
|
||||
// Introduces Bleeding: every third round its bite also opens a wound
|
||||
// that ticks for two rounds. Windows of clean rounds in between keep
|
||||
// the effect readable instead of permanent.
|
||||
abilities: {
|
||||
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
||||
},
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
},
|
||||
{
|
||||
id: ROAD_BANDIT_MONSTER_ID,
|
||||
key: 'road-bandit',
|
||||
name: 'Straßenräuber',
|
||||
name: 'Road Bandit',
|
||||
monsterCategory: MonsterCategory.HUMANOID,
|
||||
level: 2,
|
||||
maxHp: 75,
|
||||
attack: 9,
|
||||
armor: 5,
|
||||
experienceReward: 16,
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
flavorText:
|
||||
'This one has done it before, and winds up the swing where you can see it.',
|
||||
// Reinforces telegraphing: winds up a Heavy Strike every third round,
|
||||
// which Shield Bash can interrupt and Defend can blunt.
|
||||
abilities: {
|
||||
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
||||
},
|
||||
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: 'Charred Raider',
|
||||
monsterCategory: MonsterCategory.HUMANOID,
|
||||
level: 2,
|
||||
maxHp: 85,
|
||||
attack: 11,
|
||||
armor: 6,
|
||||
flavorText:
|
||||
'Burned armor, a burned face, and no interest at all in your reasons.',
|
||||
// The rare encounter: no new subsystem, just the known telegraph on a
|
||||
// tighter cadence on top of higher stats. It is winding up almost
|
||||
// constantly, so the player has to interrupt or brace.
|
||||
abilities: {
|
||||
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
||||
},
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
},
|
||||
];
|
||||
let ashRatId = ASH_RAT_MONSTER_ID;
|
||||
let roadBanditId = ROAD_BANDIT_MONSTER_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;
|
||||
const persistedId = existingMonster?.id ?? id;
|
||||
|
||||
if (existingMonster) {
|
||||
await monsterRepository.update(existingMonster.id, definition);
|
||||
@@ -139,30 +269,35 @@ export async function seedVisibleVerticalSlice(
|
||||
await monsterRepository.insert(monster);
|
||||
}
|
||||
|
||||
if (key === 'ash-rat') {
|
||||
ashRatId = persistedId;
|
||||
} else {
|
||||
roadBanditId = persistedId;
|
||||
}
|
||||
monsterIds.set(key, existingMonster?.id ?? id);
|
||||
}
|
||||
|
||||
// Weights read as "how often you meet this on the road" (spec §3). 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.
|
||||
//
|
||||
// The Charred Raider is deliberately the odd one out at weight 2: it is a
|
||||
// rare find, and `EncounterType.RARE` is what the hunt card reads to mark it
|
||||
// as such.
|
||||
const encounterPool: ReadonlyArray<{
|
||||
key: string;
|
||||
weight: number;
|
||||
encounterType: EncounterType;
|
||||
}> = [
|
||||
{ key: 'ash-rat', weight: 50, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'wild-road-dog', weight: 30, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'road-bandit', weight: 18, encounterType: EncounterType.NORMAL },
|
||||
{ key: 'charred-looter', weight: 2, encounterType: EncounterType.RARE },
|
||||
];
|
||||
|
||||
await locationMonsterRepository.upsert(
|
||||
[
|
||||
{
|
||||
encounterPool.map(({ key, weight, encounterType }) => ({
|
||||
locationId: burnedRoadId,
|
||||
monsterId: ashRatId,
|
||||
weight: 70,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
monsterId: monsterIds.get(key) as string,
|
||||
weight,
|
||||
encounterType,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
locationId: burnedRoadId,
|
||||
monsterId: roadBanditId,
|
||||
weight: 30,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
})),
|
||||
['locationId', 'monsterId'],
|
||||
);
|
||||
|
||||
@@ -174,12 +309,107 @@ export async function seedVisibleVerticalSlice(
|
||||
await characterRepository.insert({
|
||||
id: DEMO_CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
renown: 1,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: new Date(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
|
||||
// and no longer starts with the Trophy Pouch.
|
||||
//
|
||||
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
|
||||
// for free would make the slice's own showcase pointless. The Hide Bag stays
|
||||
// until Slice 0.9 grants it through the warden's referral -- removing both
|
||||
// now would drop HIDE capacity to the bagless default of 1 with no way to
|
||||
// raise it, and the hunting loop would be unplayable in between.
|
||||
//
|
||||
// Delete this block once 0.9 hands the Hide Bag over in the quest.
|
||||
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
|
||||
const existingBag = await characterLootBagRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
});
|
||||
if (!existingBag) {
|
||||
await characterLootBagRepository.insert({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
|
||||
//
|
||||
// Seeded after the character so a fresh database has somewhere to stand and
|
||||
// someone to trade with in one pass. Every link uses a stable business key
|
||||
// rather than a generated id (spec §4), and every write is an upsert, so
|
||||
// re-running the seed re-tunes prices without duplicating Borin.
|
||||
for (const npc of NPC_DEFINITIONS) {
|
||||
const locationId = locationIds.get(npc.locationKey);
|
||||
if (!locationId) {
|
||||
throw new Error(
|
||||
`NPC ${npc.key} references unknown location ${npc.locationKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { locationKey, ...definition } = npc;
|
||||
await npcDefinitionRepository.upsert({ ...definition, locationId }, [
|
||||
'key',
|
||||
]);
|
||||
}
|
||||
|
||||
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
|
||||
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
|
||||
// By id, not by (shop, item): an offer may now sell a bag instead of an
|
||||
// item, so the old pair is null for half the rows and useless as a conflict
|
||||
// target. Migration 1796 cleared the anonymous 0.8 rows for exactly this.
|
||||
await shopOfferRepository.upsert(SHOP_OFFERS, ['id']);
|
||||
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
|
||||
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
|
||||
'profileId',
|
||||
'inputItemId',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
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;
|
||||
}
|
||||
22
apps/api/src/equipment/equipment.controller.ts
Normal file
22
apps/api/src/equipment/equipment.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
apps/api/src/equipment/equipment.errors.ts
Normal file
62
apps/api/src/equipment/equipment.errors.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type EquipmentErrorCode =
|
||||
| 'CHARACTER_ITEM_NOT_FOUND'
|
||||
| 'ITEM_NOT_OWNED'
|
||||
| 'ITEM_NOT_EQUIPPABLE'
|
||||
| '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.',
|
||||
);
|
||||
}
|
||||
|
||||
// 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';
|
||||
27
apps/api/src/equipment/equipment.module.ts
Normal file
27
apps/api/src/equipment/equipment.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 { 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 {}
|
||||
519
apps/api/src/equipment/equipment.service.spec.ts
Normal file
519
apps/api/src/equipment/equipment.service.spec.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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: 'Worn Shortsword',
|
||||
description: '',
|
||||
type: ItemType.EQUIPMENT,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
rarity: ItemRarity.COMMON,
|
||||
tier: 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,
|
||||
};
|
||||
}
|
||||
|
||||
function character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
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: 'Worn Shortsword',
|
||||
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: '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,
|
||||
],
|
||||
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('equips an owned item regardless of the item content that used to carry a level requirement', async () => {
|
||||
const highTierHelm = itemDefinition({
|
||||
id: BANDIT_HOOD_ITEM_ID,
|
||||
key: 'bandit-hood',
|
||||
equipmentSlot: EquipmentSlot.HEAD,
|
||||
tier: 5,
|
||||
});
|
||||
const { service } = createHarness({
|
||||
itemDefinitions: [highTierHelm],
|
||||
characterItems: [
|
||||
{
|
||||
id: BANDIT_HOOD_ITEM_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: highTierHelm.id,
|
||||
quantity: 1,
|
||||
} as CharacterItem,
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
|
||||
|
||||
expect(result.slots.HEAD?.characterItemId).toBe(BANDIT_HOOD_ITEM_ID);
|
||||
});
|
||||
|
||||
it('rejects equipping a non-equippable item', async () => {
|
||||
const material = itemDefinition({
|
||||
id: 'def-ash-pelt',
|
||||
key: 'ash-pelt',
|
||||
type: ItemType.TRADE_GOOD,
|
||||
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: 'Padded 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
179
apps/api/src/equipment/equipment.service.ts
Normal file
179
apps/api/src/equipment/equipment.service.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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,
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
35
apps/api/src/exchanges/dto/exchange.dto.ts
Normal file
35
apps/api/src/exchanges/dto/exchange.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ExchangeItemDto {
|
||||
@IsString()
|
||||
itemKey!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class ExchangeRequestDto {
|
||||
/**
|
||||
* Item keys and quantities only.
|
||||
*
|
||||
* Prices and rewards are never accepted from the client -- the server reads
|
||||
* them from the exchange rules (slice §8, NPC spec §37.9). The upper bound
|
||||
* keeps one request from turning into an unbounded row-by-row transaction.
|
||||
*/
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ExchangeItemDto)
|
||||
items!: ExchangeItemDto[];
|
||||
}
|
||||
120
apps/api/src/exchanges/entities/exchange-rule.entity.ts
Normal file
120
apps/api/src/exchanges/entities/exchange-rule.entity.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { GameCondition } from '../../conditions/game-condition.types';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
||||
import { NpcExchangeProfile } from './npc-exchange-profile.entity';
|
||||
|
||||
/**
|
||||
* What one accepted material is worth (NPC spec §17, Playable Slice 0.8 §4).
|
||||
*
|
||||
* This is the hinge of the 0.8 loop: the kill produces the object, and this
|
||||
* row is where the object becomes economy and progression (slice §4). Values
|
||||
* are balancing content, tuned here rather than in `ExchangeService`.
|
||||
*
|
||||
* Supersedes `TurnInDefinition` from Slice 0.6.5, which mapped an item to
|
||||
* silver and reputation with no NPC, no renown and no conditions. Both alive
|
||||
* at once would have been two competing ways to sell the same pelt at
|
||||
* different prices -- exactly the second progression model slice 0.8 §6
|
||||
* forbids.
|
||||
*/
|
||||
@Entity({ name: 'exchange_rules' })
|
||||
@Index('IDX_exchange_rules_profile_item', ['profileId', 'inputItemId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class ExchangeRule {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'profile_id', type: 'uuid' })
|
||||
profileId!: string;
|
||||
|
||||
@Column({ name: 'input_item_id', type: 'uuid' })
|
||||
inputItemId!: string;
|
||||
|
||||
/**
|
||||
* How many the rule trades in one step. A rule with `inputQuantity` 5 can
|
||||
* only be redeemed in multiples of five, which is what makes spec §17's
|
||||
* "5 x Aschenfell -> 18 silver" expressible as a batch rather than as five
|
||||
* separate single-pelt payouts.
|
||||
*/
|
||||
@Column({ name: 'input_quantity', type: 'integer', default: 1 })
|
||||
inputQuantity!: number;
|
||||
|
||||
/** Which faction's regional reputation this rule pays into (slice §6). */
|
||||
@Column({ name: 'faction_id', type: 'uuid' })
|
||||
factionId!: string;
|
||||
|
||||
@Column({ name: 'silver_reward', type: 'integer', default: 0 })
|
||||
silverReward!: number;
|
||||
|
||||
@Column({ name: 'region_reputation_reward', type: 'integer', default: 0 })
|
||||
regionReputationReward!: number;
|
||||
|
||||
/**
|
||||
* World Renown, granted as a milestone rather than as points per pelt.
|
||||
*
|
||||
* Slice 0.8 §4 sketches `worldRenownPerUnit or batch rule`, and §9's example
|
||||
* response shows a flat `worldRenown: 2`. A per-unit grant is not compatible
|
||||
* with the Renown system that already exists: Slice 0.6.5 made Renown a
|
||||
* 1-15 *rank* that reassigns baseHp/baseAttack from a fixed power curve on
|
||||
* every change, and warns that "repeatedly killing weak monsters must not be
|
||||
* an efficient way to increase Renown" (0.6.5 §2.2, §4, §5). Paying renown
|
||||
* by the pelt would run a player from rank 1 to the rank-15 stat ceiling in
|
||||
* a handful of trips and flatten the whole progression curve.
|
||||
*
|
||||
* So this names a `RenownMilestoneDefinition` key instead, which is the
|
||||
* "batch rule" half of slice §4 and matches 0.6.5 §6 exactly -- it names
|
||||
* "first meaningful trophy returned" as the Renown 2 milestone. Non-
|
||||
* repeatable milestones fire once and are then silently skipped, so routine
|
||||
* trading keeps paying silver and reputation without touching renown.
|
||||
*
|
||||
* This is also literally the field 0.6.5 §19 parked for later: it lists
|
||||
* `firstTurnInMilestoneKey` as an optional addition with "do not add these
|
||||
* unless actually needed". Slice 0.8 is when it is needed.
|
||||
*
|
||||
* Null means this rule grants no renown at all, which is the normal case.
|
||||
*/
|
||||
@Column({
|
||||
name: 'renown_milestone_key',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
renownMilestoneKey!: string | null;
|
||||
|
||||
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
|
||||
conditions!: GameCondition[];
|
||||
|
||||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => NpcExchangeProfile, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'profile_id' })
|
||||
profile!: NpcExchangeProfile;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'input_item_id' })
|
||||
inputItem!: ItemDefinition;
|
||||
|
||||
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'faction_id' })
|
||||
faction!: ReputationFaction;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
|
||||
/**
|
||||
* The set of materials one NPC accepts (NPC spec §17).
|
||||
*
|
||||
* Kept apart from `NpcShop` on purpose: handing over five pelts for silver and
|
||||
* reputation is not a purchase, and modelling it as a negative-price shop
|
||||
* offer would smear two different systems together (spec §17, §37.7).
|
||||
*/
|
||||
@Entity({ name: 'npc_exchange_profiles' })
|
||||
@Index('IDX_npc_exchange_profiles_key', ['key'], { unique: true })
|
||||
@Index('IDX_npc_exchange_profiles_npc', ['npcId'])
|
||||
export class NpcExchangeProfile {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'npc_id', type: 'uuid' })
|
||||
npcId!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'npc_id' })
|
||||
npc!: NpcDefinition;
|
||||
}
|
||||
41
apps/api/src/exchanges/exchange.controller.ts
Normal file
41
apps/api/src/exchanges/exchange.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { ExchangeRequestDto } from './dto/exchange.dto';
|
||||
import {
|
||||
ExchangeResultDto,
|
||||
ExchangeService,
|
||||
ExchangeViewDto,
|
||||
} from './exchange.service';
|
||||
|
||||
/**
|
||||
* Trade-in endpoints, addressed by merchant key (Playable Slice 0.8 §9).
|
||||
*
|
||||
* The character is taken from the session-stand-in, never from the request,
|
||||
* so a caller cannot trade out of somebody else's inventory.
|
||||
*/
|
||||
@Controller('merchants/:merchantKey')
|
||||
export class ExchangeController {
|
||||
constructor(private readonly exchangeService: ExchangeService) {}
|
||||
|
||||
@Get('trade-in')
|
||||
getTradeIn(
|
||||
@Param('merchantKey') merchantKey: string,
|
||||
): Promise<ExchangeViewDto> {
|
||||
return this.exchangeService.getMerchantExchangeView(
|
||||
DEMO_CHARACTER_ID,
|
||||
merchantKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('trade-in')
|
||||
tradeIn(
|
||||
@Param('merchantKey') merchantKey: string,
|
||||
@Body() request: ExchangeRequestDto,
|
||||
): Promise<ExchangeResultDto> {
|
||||
return this.exchangeService.exchangeWithMerchant(
|
||||
DEMO_CHARACTER_ID,
|
||||
merchantKey,
|
||||
request.items,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
apps/api/src/exchanges/exchange.errors.ts
Normal file
71
apps/api/src/exchanges/exchange.errors.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type ExchangeErrorCode =
|
||||
| 'EXCHANGE_NOT_FOUND'
|
||||
| 'EXCHANGE_DISABLED'
|
||||
| 'EXCHANGE_ITEM_NOT_ACCEPTED'
|
||||
| 'EXCHANGE_INVALID_QUANTITY'
|
||||
| 'EXCHANGE_INSUFFICIENT_QUANTITY'
|
||||
| 'EXCHANGE_EMPTY_REQUEST';
|
||||
|
||||
export class ExchangeDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: ExchangeErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function exchangeNotFound(): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This merchant does not trade in goods.',
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeDisabled(): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_DISABLED',
|
||||
HttpStatus.CONFLICT,
|
||||
'This merchant is not trading right now.',
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeItemNotAccepted(itemKey: string): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_ITEM_NOT_ACCEPTED',
|
||||
HttpStatus.CONFLICT,
|
||||
`This merchant does not accept ${itemKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeInvalidQuantity(): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_INVALID_QUANTITY',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Quantity must be a positive whole number.',
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeInsufficientQuantity(
|
||||
itemKey: string,
|
||||
): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_INSUFFICIENT_QUANTITY',
|
||||
HttpStatus.CONFLICT,
|
||||
`You are not carrying that many ${itemKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeEmptyRequest(): ExchangeDomainError {
|
||||
return new ExchangeDomainError(
|
||||
'EXCHANGE_EMPTY_REQUEST',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Select at least one item to trade.',
|
||||
);
|
||||
}
|
||||
|
||||
export { characterNotFound } from '../travel/travel.errors';
|
||||
577
apps/api/src/exchanges/exchange.service.spec.ts
Normal file
577
apps/api/src/exchanges/exchange.service.spec.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { LootCapacityService } from '../loot-bags/loot-capacity.service';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { RenownService } from '../renown/renown.service';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { ReputationService } from '../reputation/reputation.service';
|
||||
import { ExchangeRule } from './entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
|
||||
import { ExchangeService } from './exchange.service';
|
||||
|
||||
const CHARACTER_ID = 'character-1';
|
||||
const PROFILE_ID = 'profile-1';
|
||||
const FACTION_ID = 'faction-1';
|
||||
const PROFILE_KEY = 'borin-trade-in';
|
||||
|
||||
interface ItemRow {
|
||||
id: string;
|
||||
characterId: string;
|
||||
itemDefinitionId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface WorldFixture {
|
||||
silver?: number;
|
||||
renown?: number;
|
||||
carried?: ItemRow[];
|
||||
profileEnabled?: boolean;
|
||||
milestoneAlreadyDone?: boolean;
|
||||
ruleConditionsMet?: boolean;
|
||||
}
|
||||
|
||||
class MilestoneAlreadyCompleted extends Error {
|
||||
readonly code = 'RENOWN_MILESTONE_ALREADY_COMPLETED';
|
||||
}
|
||||
|
||||
function rule(overrides: Partial<ExchangeRule> = {}): ExchangeRule {
|
||||
return {
|
||||
id: 'rule-pelt',
|
||||
profileId: PROFILE_ID,
|
||||
inputItemId: 'item-pelt',
|
||||
inputQuantity: 1,
|
||||
factionId: FACTION_ID,
|
||||
silverReward: 5,
|
||||
regionReputationReward: 2,
|
||||
renownMilestoneKey: 'first-goods-returned',
|
||||
conditions: [],
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
inputItem: { id: 'item-pelt', key: 'ash-pelt', name: 'Ashen Pelt' },
|
||||
...overrides,
|
||||
} as unknown as ExchangeRule;
|
||||
}
|
||||
|
||||
function createWorld(fixture: WorldFixture = {}) {
|
||||
const character = {
|
||||
id: CHARACTER_ID,
|
||||
silver: fixture.silver ?? 0,
|
||||
renown: fixture.renown ?? 1,
|
||||
} as Character;
|
||||
|
||||
const items: ItemRow[] = (fixture.carried ?? []).map((row) => ({ ...row }));
|
||||
const removed: ItemRow[] = [];
|
||||
|
||||
const rules = [
|
||||
rule(),
|
||||
rule({
|
||||
id: 'rule-insignia',
|
||||
inputItemId: 'item-insignia',
|
||||
silverReward: 30,
|
||||
regionReputationReward: 12,
|
||||
inputItem: {
|
||||
id: 'item-insignia',
|
||||
key: 'charred-raider-insignia',
|
||||
name: 'Charred Raider Insignia',
|
||||
},
|
||||
} as Partial<ExchangeRule>),
|
||||
rule({
|
||||
id: 'rule-batch',
|
||||
inputItemId: 'item-batch',
|
||||
inputQuantity: 5,
|
||||
silverReward: 40,
|
||||
regionReputationReward: 10,
|
||||
renownMilestoneKey: null,
|
||||
inputItem: { id: 'item-batch', key: 'tough-hide', name: 'Tough Hide' },
|
||||
} as Partial<ExchangeRule>),
|
||||
];
|
||||
|
||||
const repositories = (entity: unknown) => {
|
||||
if (entity === Character) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(character),
|
||||
findOneBy: () => Promise.resolve(character),
|
||||
findOneByOrFail: () => Promise.resolve(character),
|
||||
save: (row: Character) => Promise.resolve(row),
|
||||
};
|
||||
}
|
||||
if (entity === NpcExchangeProfile) {
|
||||
return {
|
||||
findOne: () =>
|
||||
Promise.resolve({
|
||||
id: PROFILE_ID,
|
||||
key: PROFILE_KEY,
|
||||
npcId: 'npc-1',
|
||||
name: 'Border Watch Trade-In',
|
||||
enabled: fixture.profileEnabled ?? true,
|
||||
npc: { key: 'borin-quartermaster' },
|
||||
}),
|
||||
findOneBy: () =>
|
||||
Promise.resolve({
|
||||
id: PROFILE_ID,
|
||||
key: PROFILE_KEY,
|
||||
npcId: 'npc-1',
|
||||
name: 'Border Watch Trade-In',
|
||||
enabled: fixture.profileEnabled ?? true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === ExchangeRule) {
|
||||
return {
|
||||
find: () =>
|
||||
Promise.resolve(
|
||||
rules.map((entry) => ({
|
||||
...entry,
|
||||
faction: { key: 'border-guard', name: 'Border Watch' },
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === CharacterItem) {
|
||||
return {
|
||||
find: () => Promise.resolve(items),
|
||||
findOne: (options: { where: { itemDefinitionId: string } }) =>
|
||||
Promise.resolve(
|
||||
items.find(
|
||||
(row) => row.itemDefinitionId === options.where.itemDefinitionId,
|
||||
) ?? null,
|
||||
),
|
||||
remove: async (row: ItemRow) => {
|
||||
const index = items.findIndex((candidate) => candidate.id === row.id);
|
||||
if (index >= 0) {
|
||||
removed.push(items.splice(index, 1)[0]);
|
||||
}
|
||||
},
|
||||
save: (row: ItemRow) => Promise.resolve(row),
|
||||
};
|
||||
}
|
||||
if (entity === ReputationFaction) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve({ id: FACTION_ID, key: 'border-guard' }),
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected repository');
|
||||
};
|
||||
|
||||
const manager = { getRepository: repositories } as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
getRepository: repositories,
|
||||
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
|
||||
run(manager),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const reputationGrants: Array<{ factionKey: string; amount: number }> = [];
|
||||
const reputation = {
|
||||
grantReputation: jest.fn(
|
||||
async (_characterId: string, factionKey: string, amount: number) => {
|
||||
reputationGrants.push({ factionKey, amount });
|
||||
return {
|
||||
factionKey,
|
||||
previousReputation: 0,
|
||||
newReputation: amount,
|
||||
previousRank: 'STRANGER',
|
||||
newRank: 'STRANGER',
|
||||
rankChanged: false,
|
||||
};
|
||||
},
|
||||
),
|
||||
} as unknown as ReputationService;
|
||||
|
||||
const milestoneCalls: string[] = [];
|
||||
const renown = {
|
||||
completeMilestone: jest.fn(async (_characterId: string, key: string) => {
|
||||
milestoneCalls.push(key);
|
||||
if (fixture.milestoneAlreadyDone) {
|
||||
throw new MilestoneAlreadyCompleted();
|
||||
}
|
||||
character.renown += 1;
|
||||
return {
|
||||
milestoneKey: key,
|
||||
previousRenown: character.renown - 1,
|
||||
newRenown: character.renown,
|
||||
renownGranted: true,
|
||||
};
|
||||
}),
|
||||
} as unknown as RenownService;
|
||||
|
||||
const lootCapacity = {
|
||||
getCapacities: jest.fn(() =>
|
||||
Promise.resolve([
|
||||
{ category: 'HIDE', current: 0, capacity: 5, bag: null },
|
||||
]),
|
||||
),
|
||||
} as unknown as LootCapacityService;
|
||||
|
||||
const conditions = {
|
||||
evaluate: jest.fn(() => Promise.resolve(fixture.ruleConditionsMet ?? true)),
|
||||
} as unknown as GameConditionService;
|
||||
|
||||
const npcs = {
|
||||
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
|
||||
} as unknown as NpcService;
|
||||
|
||||
const service = new ExchangeService(
|
||||
dataSource,
|
||||
reputation,
|
||||
renown,
|
||||
lootCapacity,
|
||||
conditions,
|
||||
npcs,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
character,
|
||||
items,
|
||||
removed,
|
||||
reputationGrants,
|
||||
milestoneCalls,
|
||||
reputation,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExchangeService', () => {
|
||||
it('pays Silver and regional reputation from content, not from the request', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 5 },
|
||||
]);
|
||||
|
||||
// 5 pelts at 5 silver and 2 reputation each.
|
||||
expect(result.rewards.silver).toBe(25);
|
||||
expect(result.rewards.regionalReputation).toBe(10);
|
||||
expect(world.character.silver).toBe(25);
|
||||
expect(world.reputationGrants).toEqual([
|
||||
{ factionKey: 'border-guard', amount: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes exactly the traded quantity and leaves the rest', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 2 },
|
||||
]);
|
||||
|
||||
expect(world.items[0].quantity).toBe(3);
|
||||
});
|
||||
|
||||
it('deletes the stack when the last one is traded, freeing bag capacity', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 3 },
|
||||
]);
|
||||
|
||||
// Capacity is derived from owned items, so removing the stack is what
|
||||
// frees the bag (slice §11).
|
||||
expect(world.items).toHaveLength(0);
|
||||
expect(world.removed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a quantity the character does not carry', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 3 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
|
||||
|
||||
expect(world.character.silver).toBe(0);
|
||||
expect(world.items[0].quantity).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects an item this merchant does not accept', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-sword',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'worn-short-sword', quantity: 1 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
|
||||
});
|
||||
|
||||
it('cannot be tricked into overdrawing a stack by repeating one item', async () => {
|
||||
// Each line would individually pass against an untouched stack of three.
|
||||
// Folding the request together first is what stops six pelts leaving a
|
||||
// three-pelt stack.
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 3 },
|
||||
{ itemKey: 'ash-pelt', quantity: 3 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
|
||||
|
||||
expect(world.character.silver).toBe(0);
|
||||
expect(world.items[0].quantity).toBe(3);
|
||||
});
|
||||
|
||||
it('trades several different goods in one handover', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 2,
|
||||
},
|
||||
{
|
||||
id: 'ci-2',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-insignia',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 2 },
|
||||
{ itemKey: 'charred-raider-insignia', quantity: 1 },
|
||||
]);
|
||||
|
||||
expect(result.rewards.silver).toBe(2 * 5 + 30);
|
||||
expect(result.consumed).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('only trades a batch rule in whole steps', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-batch',
|
||||
quantity: 7,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'tough-hide', quantity: 3 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
|
||||
|
||||
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'tough-hide', quantity: 5 },
|
||||
]);
|
||||
expect(result.rewards.silver).toBe(40);
|
||||
});
|
||||
|
||||
it('grants the renown milestone on the first trade', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 1 },
|
||||
]);
|
||||
|
||||
expect(result.rewards.worldRenown).toBe(1);
|
||||
expect(result.renownMilestonesCompleted).toEqual(['first-goods-returned']);
|
||||
expect(result.balances.worldRenown).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps paying Silver once the renown milestone is spent', async () => {
|
||||
// The second trade onward: the milestone is done, which is normal rather
|
||||
// than an error. Silver and reputation must still land.
|
||||
const world = createWorld({
|
||||
milestoneAlreadyDone: true,
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 4 },
|
||||
]);
|
||||
|
||||
expect(result.rewards.worldRenown).toBe(0);
|
||||
expect(result.renownMilestonesCompleted).toEqual([]);
|
||||
expect(result.rewards.silver).toBe(20);
|
||||
expect(result.balances.worldRenown).toBe(1);
|
||||
});
|
||||
|
||||
it('claims one milestone once even when several goods name it', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: 'ci-2',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-insignia',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 1 },
|
||||
{ itemKey: 'charred-raider-insignia', quantity: 1 },
|
||||
]);
|
||||
|
||||
expect(world.milestoneCalls).toEqual(['first-goods-returned']);
|
||||
});
|
||||
|
||||
it('refuses an empty request rather than committing a no-op trade', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, []),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_EMPTY_REQUEST' });
|
||||
});
|
||||
|
||||
it('refuses a zero or fractional quantity', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 0 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 1.5 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
|
||||
});
|
||||
|
||||
it('refuses to trade into a disabled profile', async () => {
|
||||
const world = createWorld({ profileEnabled: false });
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 1 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_DISABLED' });
|
||||
});
|
||||
|
||||
it('refuses a rule whose conditions are not met, server-side', async () => {
|
||||
const world = createWorld({
|
||||
ruleConditionsMet: false,
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
|
||||
{ itemKey: 'ash-pelt', quantity: 1 },
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
|
||||
});
|
||||
|
||||
it('shows what is carried and what it is worth', async () => {
|
||||
const world = createWorld({
|
||||
carried: [
|
||||
{
|
||||
id: 'ci-1',
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-pelt',
|
||||
quantity: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
|
||||
|
||||
const pelt = view.offers.find((offer) => offer.itemKey === 'ash-pelt');
|
||||
expect(pelt).toMatchObject({ quantityCarried: 4, silverPerStep: 5 });
|
||||
// Goods the character is not carrying still appear, so the player can see
|
||||
// what this merchant would take.
|
||||
expect(
|
||||
view.offers.find((offer) => offer.itemKey === 'tough-hide'),
|
||||
).toMatchObject({ quantityCarried: 0 });
|
||||
});
|
||||
|
||||
it('hides a locked rule from the view entirely', async () => {
|
||||
const world = createWorld({ ruleConditionsMet: false });
|
||||
|
||||
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
|
||||
|
||||
expect(view.offers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
506
apps/api/src/exchanges/exchange.service.ts
Normal file
506
apps/api/src/exchanges/exchange.service.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
|
||||
import {
|
||||
LootCapacityDto,
|
||||
LootCapacityService,
|
||||
} from '../loot-bags/loot-capacity.service';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { RenownService } from '../renown/renown.service';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { ReputationService } from '../reputation/reputation.service';
|
||||
import { ExchangeRule } from './entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
|
||||
import {
|
||||
characterNotFound,
|
||||
exchangeDisabled,
|
||||
exchangeEmptyRequest,
|
||||
exchangeInsufficientQuantity,
|
||||
exchangeInvalidQuantity,
|
||||
exchangeItemNotAccepted,
|
||||
exchangeNotFound,
|
||||
} from './exchange.errors';
|
||||
|
||||
export interface ExchangeOfferDto {
|
||||
itemKey: string;
|
||||
itemName: string;
|
||||
iconPath: string;
|
||||
/** How many the character is carrying right now. */
|
||||
quantityCarried: number;
|
||||
/** Smallest tradeable step. Quantities must be a multiple of this. */
|
||||
inputQuantity: number;
|
||||
silverPerStep: number;
|
||||
reputationPerStep: number;
|
||||
factionKey: string;
|
||||
factionName: string;
|
||||
/** The renown milestone this rule can still award, if any. */
|
||||
renownMilestoneKey: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeViewDto {
|
||||
profileKey: string;
|
||||
profileName: string;
|
||||
npcKey: string;
|
||||
offers: ExchangeOfferDto[];
|
||||
capacities: LootCapacityDto[];
|
||||
}
|
||||
|
||||
export interface ExchangeRequestItem {
|
||||
itemKey: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface ExchangeConsumedDto {
|
||||
itemKey: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface ExchangeResultDto {
|
||||
profileKey: string;
|
||||
consumed: ExchangeConsumedDto[];
|
||||
rewards: {
|
||||
silver: number;
|
||||
regionalReputation: number;
|
||||
worldRenown: number;
|
||||
};
|
||||
balances: {
|
||||
silver: number;
|
||||
regionalReputation: number;
|
||||
worldRenown: number;
|
||||
};
|
||||
/** Ranks crossed by this trade, so the UI can call them out. */
|
||||
reputationRankChanged: boolean;
|
||||
newReputationRank: string | null;
|
||||
/** Milestone keys this trade completed. Empty on every later trade. */
|
||||
renownMilestonesCompleted: string[];
|
||||
capacities: LootCapacityDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns carried materials into Silver, regional reputation and World Renown
|
||||
* (NPC spec §17, §30; Playable Slice 0.8 §4, §8).
|
||||
*
|
||||
* This is the only place a normal hunt becomes progression: kills grant no
|
||||
* money or reputation at all (slice 0.7 §7), so everything the player earns
|
||||
* passes through here.
|
||||
*
|
||||
* Every reward is computed from persisted content. The request carries item
|
||||
* keys and quantities and nothing else -- never a price, never a reward
|
||||
* (spec §37.9).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExchangeService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly reputation: ReputationService,
|
||||
private readonly renown: RenownService,
|
||||
private readonly lootCapacity: LootCapacityService,
|
||||
private readonly conditions: GameConditionService,
|
||||
private readonly npcs: NpcService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The trade-in view for a merchant, addressed by NPC key (slice §9).
|
||||
*
|
||||
* Reachability is checked first: the character must be standing where the
|
||||
* merchant is, so a client cannot sell into Graufurt from the Burned Road.
|
||||
*/
|
||||
async getMerchantExchangeView(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
): Promise<ExchangeViewDto> {
|
||||
const profileKey = await this.resolveMerchantProfileKey(
|
||||
characterId,
|
||||
merchantKey,
|
||||
);
|
||||
return this.getExchangeView(characterId, profileKey);
|
||||
}
|
||||
|
||||
/** Trades goods in with a merchant addressed by NPC key (slice §9). */
|
||||
async exchangeWithMerchant(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
requested: ExchangeRequestItem[],
|
||||
): Promise<ExchangeResultDto> {
|
||||
const profileKey = await this.resolveMerchantProfileKey(
|
||||
characterId,
|
||||
merchantKey,
|
||||
);
|
||||
return this.exchange(characterId, profileKey, requested);
|
||||
}
|
||||
|
||||
private async resolveMerchantProfileKey(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
): Promise<string> {
|
||||
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
|
||||
const profile = await this.dataSource
|
||||
.getRepository(NpcExchangeProfile)
|
||||
.findOneBy({ npcId: npc.id, enabled: true });
|
||||
if (!profile) {
|
||||
throw exchangeNotFound();
|
||||
}
|
||||
return profile.key;
|
||||
}
|
||||
|
||||
/** What this merchant will take, and what the character is carrying. */
|
||||
async getExchangeView(
|
||||
characterId: string,
|
||||
profileKey: string,
|
||||
): Promise<ExchangeViewDto> {
|
||||
const profile = await this.dataSource
|
||||
.getRepository(NpcExchangeProfile)
|
||||
.findOne({ where: { key: profileKey }, relations: { npc: true } });
|
||||
if (!profile) {
|
||||
throw exchangeNotFound();
|
||||
}
|
||||
if (!profile.enabled) {
|
||||
throw exchangeDisabled();
|
||||
}
|
||||
|
||||
const rules = await this.dataSource.getRepository(ExchangeRule).find({
|
||||
where: { profileId: profile.id, enabled: true },
|
||||
relations: { inputItem: true, faction: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
const carried = await this.loadCarriedQuantities(
|
||||
characterId,
|
||||
rules.map((rule) => rule.inputItemId),
|
||||
this.dataSource,
|
||||
);
|
||||
|
||||
const offers: ExchangeOfferDto[] = [];
|
||||
for (const rule of rules) {
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId: profile.npcId },
|
||||
rule.conditions,
|
||||
);
|
||||
if (!unlocked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
offers.push({
|
||||
itemKey: rule.inputItem.key,
|
||||
itemName: rule.inputItem.name,
|
||||
iconPath: rule.inputItem.iconPath,
|
||||
quantityCarried: carried.get(rule.inputItemId) ?? 0,
|
||||
inputQuantity: rule.inputQuantity,
|
||||
silverPerStep: rule.silverReward,
|
||||
reputationPerStep: rule.regionReputationReward,
|
||||
factionKey: rule.faction.key,
|
||||
factionName: rule.faction.name,
|
||||
renownMilestoneKey: rule.renownMilestoneKey,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profileKey: profile.key,
|
||||
profileName: profile.name,
|
||||
npcKey: profile.npc.key,
|
||||
offers,
|
||||
capacities: await this.lootCapacity.getCapacities(characterId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands goods over and pays out, all or nothing (slice §8).
|
||||
*
|
||||
* The whole thing runs in one transaction: goods are removed, Silver
|
||||
* credited, reputation granted and any renown milestone completed together,
|
||||
* so a failure anywhere leaves the character exactly as they were and the
|
||||
* same pelt can never be sold twice.
|
||||
*/
|
||||
async exchange(
|
||||
characterId: string,
|
||||
profileKey: string,
|
||||
requested: ExchangeRequestItem[],
|
||||
): Promise<ExchangeResultDto> {
|
||||
const merged = this.mergeRequest(requested);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const character = await manager.getRepository(Character).findOne({
|
||||
where: { id: characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw characterNotFound();
|
||||
}
|
||||
|
||||
const profile = await manager
|
||||
.getRepository(NpcExchangeProfile)
|
||||
.findOneBy({ key: profileKey });
|
||||
if (!profile) {
|
||||
throw exchangeNotFound();
|
||||
}
|
||||
if (!profile.enabled) {
|
||||
throw exchangeDisabled();
|
||||
}
|
||||
|
||||
const rules = await manager.getRepository(ExchangeRule).find({
|
||||
where: { profileId: profile.id, enabled: true },
|
||||
relations: { inputItem: true },
|
||||
});
|
||||
const rulesByItemKey = new Map(
|
||||
rules.map((rule) => [rule.inputItem.key, rule]),
|
||||
);
|
||||
|
||||
let silverGranted = 0;
|
||||
const reputationByFaction = new Map<string, number>();
|
||||
const milestoneKeys: string[] = [];
|
||||
const consumed: ExchangeConsumedDto[] = [];
|
||||
|
||||
for (const [itemKey, quantity] of merged) {
|
||||
const rule = rulesByItemKey.get(itemKey);
|
||||
if (!rule) {
|
||||
throw exchangeItemNotAccepted(itemKey);
|
||||
}
|
||||
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId: profile.npcId },
|
||||
rule.conditions,
|
||||
manager,
|
||||
);
|
||||
if (!unlocked) {
|
||||
throw exchangeItemNotAccepted(itemKey);
|
||||
}
|
||||
|
||||
// A batch rule only trades in whole steps: five pelts for a fixed
|
||||
// payout cannot be redeemed three at a time.
|
||||
if (quantity % rule.inputQuantity !== 0) {
|
||||
throw exchangeInvalidQuantity();
|
||||
}
|
||||
|
||||
await this.consumeItems(manager, characterId, rule, quantity, itemKey);
|
||||
|
||||
const steps = quantity / rule.inputQuantity;
|
||||
silverGranted += rule.silverReward * steps;
|
||||
reputationByFaction.set(
|
||||
rule.factionId,
|
||||
(reputationByFaction.get(rule.factionId) ?? 0) +
|
||||
rule.regionReputationReward * steps,
|
||||
);
|
||||
if (rule.renownMilestoneKey) {
|
||||
milestoneKeys.push(rule.renownMilestoneKey);
|
||||
}
|
||||
|
||||
consumed.push({
|
||||
itemKey,
|
||||
itemName: rule.inputItem.name,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
character.silver += silverGranted;
|
||||
await manager.getRepository(Character).save(character);
|
||||
|
||||
const reputationResult = await this.grantReputation(
|
||||
manager,
|
||||
characterId,
|
||||
reputationByFaction,
|
||||
);
|
||||
|
||||
const renownResult = await this.grantRenown(
|
||||
manager,
|
||||
characterId,
|
||||
milestoneKeys,
|
||||
);
|
||||
|
||||
// Re-read: RenownService rewrites base stats when a milestone lands, so
|
||||
// the row loaded at the top of the transaction is already stale.
|
||||
const settled = await manager
|
||||
.getRepository(Character)
|
||||
.findOneByOrFail({ id: characterId });
|
||||
|
||||
return {
|
||||
profileKey,
|
||||
consumed,
|
||||
rewards: {
|
||||
silver: silverGranted,
|
||||
regionalReputation: reputationResult.granted,
|
||||
worldRenown: renownResult.granted,
|
||||
},
|
||||
balances: {
|
||||
silver: settled.silver,
|
||||
regionalReputation: reputationResult.balance,
|
||||
worldRenown: settled.renown,
|
||||
},
|
||||
reputationRankChanged: reputationResult.rankChanged,
|
||||
newReputationRank: reputationResult.newRank,
|
||||
renownMilestonesCompleted: renownResult.completed,
|
||||
capacities: await this.lootCapacity.getCapacities(characterId, manager),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a request down to one entry per item.
|
||||
*
|
||||
* Without this, a client could send the same key twice and have each line
|
||||
* pass the ownership check against the same untouched stack -- trading three
|
||||
* pelts twice while carrying three.
|
||||
*/
|
||||
private mergeRequest(requested: ExchangeRequestItem[]): Map<string, number> {
|
||||
if (!requested || requested.length === 0) {
|
||||
throw exchangeEmptyRequest();
|
||||
}
|
||||
|
||||
const merged = new Map<string, number>();
|
||||
for (const entry of requested) {
|
||||
if (!Number.isInteger(entry.quantity) || entry.quantity <= 0) {
|
||||
throw exchangeInvalidQuantity();
|
||||
}
|
||||
merged.set(
|
||||
entry.itemKey,
|
||||
(merged.get(entry.itemKey) ?? 0) + entry.quantity,
|
||||
);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Removes exactly `quantity`, deleting the stack when it empties. */
|
||||
private async consumeItems(
|
||||
manager: EntityManager,
|
||||
characterId: string,
|
||||
rule: ExchangeRule,
|
||||
quantity: number,
|
||||
itemKey: string,
|
||||
): Promise<void> {
|
||||
const characterItems = manager.getRepository(CharacterItem);
|
||||
const owned = await characterItems.findOne({
|
||||
where: { characterId, itemDefinitionId: rule.inputItemId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!owned || owned.quantity < quantity) {
|
||||
throw exchangeInsufficientQuantity(itemKey);
|
||||
}
|
||||
|
||||
if (owned.quantity === quantity) {
|
||||
await characterItems.remove(owned);
|
||||
return;
|
||||
}
|
||||
|
||||
owned.quantity -= quantity;
|
||||
await characterItems.save(owned);
|
||||
}
|
||||
|
||||
private async grantReputation(
|
||||
manager: EntityManager,
|
||||
characterId: string,
|
||||
amountByFaction: Map<string, number>,
|
||||
): Promise<{
|
||||
granted: number;
|
||||
balance: number;
|
||||
rankChanged: boolean;
|
||||
newRank: string | null;
|
||||
}> {
|
||||
let granted = 0;
|
||||
let balance = 0;
|
||||
let rankChanged = false;
|
||||
let newRank: string | null = null;
|
||||
|
||||
for (const [factionId, amount] of amountByFaction) {
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const faction = await manager
|
||||
.getRepository(ReputationFaction)
|
||||
.findOneBy({ id: factionId });
|
||||
if (!faction) {
|
||||
throw exchangeNotFound();
|
||||
}
|
||||
|
||||
const result = await this.reputation.grantReputation(
|
||||
characterId,
|
||||
faction.key,
|
||||
amount,
|
||||
manager,
|
||||
);
|
||||
|
||||
granted += amount;
|
||||
// Slice 0.8 reports a single regional reputation figure. Every seeded
|
||||
// rule pays the same region, so this is that region's balance; if a
|
||||
// profile ever spans factions, the last one wins and the response shape
|
||||
// needs to grow into a list.
|
||||
balance = result.newReputation;
|
||||
rankChanged = rankChanged || result.rankChanged;
|
||||
newRank = result.rankChanged ? result.newRank : newRank;
|
||||
}
|
||||
|
||||
return { granted, balance, rankChanged, newRank };
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes any renown milestones this trade earned.
|
||||
*
|
||||
* A milestone that is already done is not an error -- it is the normal case
|
||||
* from the second trade onward -- so `RenownService`'s "already completed"
|
||||
* rejection is swallowed rather than allowed to fail the trade.
|
||||
*/
|
||||
private async grantRenown(
|
||||
manager: EntityManager,
|
||||
characterId: string,
|
||||
milestoneKeys: string[],
|
||||
): Promise<{ granted: number; completed: string[] }> {
|
||||
let granted = 0;
|
||||
const completed: string[] = [];
|
||||
|
||||
for (const key of new Set(milestoneKeys)) {
|
||||
try {
|
||||
const result = await this.renown.completeMilestone(
|
||||
characterId,
|
||||
key,
|
||||
manager,
|
||||
);
|
||||
if (result.renownGranted) {
|
||||
granted += result.newRenown - result.previousRenown;
|
||||
completed.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.isAlreadyCompleted(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, completed };
|
||||
}
|
||||
|
||||
private isAlreadyCompleted(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'RENOWN_MILESTONE_ALREADY_COMPLETED'
|
||||
);
|
||||
}
|
||||
|
||||
private async loadCarriedQuantities(
|
||||
characterId: string,
|
||||
itemDefinitionIds: string[],
|
||||
scope: Pick<DataSource, 'getRepository'>,
|
||||
): Promise<Map<string, number>> {
|
||||
if (itemDefinitionIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const owned = await scope.getRepository(CharacterItem).find({
|
||||
where: { characterId, itemDefinitionId: In(itemDefinitionIds) },
|
||||
});
|
||||
|
||||
const totals = new Map<string, number>();
|
||||
for (const item of owned) {
|
||||
totals.set(
|
||||
item.itemDefinitionId,
|
||||
(totals.get(item.itemDefinitionId) ?? 0) + item.quantity,
|
||||
);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
}
|
||||
42
apps/api/src/exchanges/exchanges.module.ts
Normal file
42
apps/api/src/exchanges/exchanges.module.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { ConditionsModule } from '../conditions/conditions.module';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { LootBagsModule } from '../loot-bags/loot-bags.module';
|
||||
import { NpcsModule } from '../npcs/npcs.module';
|
||||
import { RenownModule } from '../renown/renown.module';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { ReputationModule } from '../reputation/reputation.module';
|
||||
import { ExchangeRule } from './entities/exchange-rule.entity';
|
||||
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
|
||||
import { ExchangeController } from './exchange.controller';
|
||||
import { ExchangeService } from './exchange.service';
|
||||
|
||||
/**
|
||||
* Materials in, progression out (NPC spec §17; Playable Slice 0.8).
|
||||
*
|
||||
* Pulls in reputation and renown rather than reimplementing either, so the
|
||||
* trade-in feeds the progression systems that already exist instead of
|
||||
* inventing a competing one (slice §6).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Character,
|
||||
CharacterItem,
|
||||
ExchangeRule,
|
||||
NpcExchangeProfile,
|
||||
ReputationFaction,
|
||||
]),
|
||||
ConditionsModule,
|
||||
LootBagsModule,
|
||||
NpcsModule,
|
||||
RenownModule,
|
||||
ReputationModule,
|
||||
],
|
||||
controllers: [ExchangeController],
|
||||
providers: [ExchangeService],
|
||||
exports: [ExchangeService],
|
||||
})
|
||||
export class ExchangesModule {}
|
||||
@@ -6,7 +6,7 @@ const CHARACTER = { attack: 6, armor: 0, hp: 100 };
|
||||
const CHARACTER_POWER = 44;
|
||||
|
||||
describe('calculateDangerRating', () => {
|
||||
it('rates the seeded Aschenratte as MATCH', () => {
|
||||
it('rates the seeded Ash Rat 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
|
||||
@@ -15,7 +15,7 @@ describe('calculateDangerRating', () => {
|
||||
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
|
||||
});
|
||||
|
||||
it('rates the seeded Straßenräuber as STRONG', () => {
|
||||
it('rates the seeded Road Bandit 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { HuntEncounterStatus } from '../hunt-encounter-status.enum';
|
||||
import { Hunt } from './hunt.entity';
|
||||
|
||||
@Entity({ name: 'hunt_encounters' })
|
||||
@@ -23,6 +24,17 @@ export class HuntEncounter {
|
||||
@Column({ name: 'position', type: 'integer' })
|
||||
position!: number;
|
||||
|
||||
// Owned by the combat module, which advances it as fights start and end.
|
||||
// DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the
|
||||
// encounter to AVAILABLE so the player can try again.
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: HuntEncounterStatus,
|
||||
enumName: 'hunt_encounter_status_enum',
|
||||
})
|
||||
status!: HuntEncounterStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum HuntEncounterStatus {
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
DEFEATED = 'DEFEATED',
|
||||
}
|
||||
@@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service';
|
||||
describe('HuntingController', () => {
|
||||
let app: INestApplication<App>;
|
||||
const startHunt = jest.fn();
|
||||
const getActiveHunt = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
startHunt.mockReset();
|
||||
getActiveHunt.mockReset();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [HuntingController],
|
||||
providers: [
|
||||
{
|
||||
provide: HuntingService,
|
||||
useValue: { startHunt },
|
||||
useValue: { startHunt, getActiveHunt },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@@ -35,7 +37,7 @@ describe('HuntingController', () => {
|
||||
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' },
|
||||
location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
|
||||
encounters: [],
|
||||
};
|
||||
startHunt.mockResolvedValue(huntResult);
|
||||
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
|
||||
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(response.body).toEqual(huntResult);
|
||||
});
|
||||
|
||||
it('serves the resumable hunt with its encounter statuses', async () => {
|
||||
const huntResult = {
|
||||
id: 'hunt-1',
|
||||
location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
|
||||
encounters: [
|
||||
{
|
||||
id: 'encounter-1',
|
||||
monster: {
|
||||
key: 'ash-rat',
|
||||
name: 'Ash Rat',
|
||||
level: 1,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
},
|
||||
dangerRating: 'WEAK',
|
||||
status: 'DEFEATED',
|
||||
},
|
||||
],
|
||||
};
|
||||
getActiveHunt.mockResolvedValue(huntResult);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/hunts/active')
|
||||
.expect(200);
|
||||
|
||||
expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(response.body).toEqual(huntResult);
|
||||
});
|
||||
|
||||
it('serves an empty body when there is no resumable hunt', async () => {
|
||||
getActiveHunt.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/hunts/active')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post } from '@nestjs/common';
|
||||
import { Controller, Get, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { HuntResultDto, HuntingService } from './hunting.service';
|
||||
|
||||
@@ -10,4 +10,9 @@ export class HuntingController {
|
||||
startHunt(): Promise<HuntResultDto> {
|
||||
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
getActiveHunt(): Promise<HuntResultDto | null> {
|
||||
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Hunt } from './entities/hunt.entity';
|
||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||
import { HuntingController } from './hunting.controller';
|
||||
import { HuntingService } from './hunting.service';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from './random-source';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -9,18 +9,23 @@ import { LocationDefinition } from '../world/entities/location-definition.entity
|
||||
import { DangerRating } from './danger-rating';
|
||||
import { Hunt } from './entities/hunt.entity';
|
||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
||||
import { HuntStatus } from './hunt-status.enum';
|
||||
import { HuntingDomainError } from './hunting.errors';
|
||||
import { HuntingService } from './hunting.service';
|
||||
import type { RandomSource } from './random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
||||
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 MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat
|
||||
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit
|
||||
const MONSTER_C_ID = '30000000-0000-4000-8000-000000000003'; // Feral Road Hound
|
||||
const MONSTER_D_ID = '30000000-0000-4000-8000-000000000004'; // Charred Raider
|
||||
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
|
||||
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
|
||||
const LOCATION_MONSTER_C_ID = '40000000-0000-4000-8000-000000000003';
|
||||
const LOCATION_MONSTER_D_ID = '40000000-0000-4000-8000-000000000004';
|
||||
|
||||
interface FakeState {
|
||||
characters: Character[];
|
||||
@@ -29,6 +34,15 @@ interface FakeState {
|
||||
huntEncounters: HuntEncounter[];
|
||||
}
|
||||
|
||||
// `find` in the fake ignores `relations`, so fixtures attach the joined
|
||||
// monster the way TypeORM would have hydrated it.
|
||||
function withMonster(
|
||||
encounter: HuntEncounter,
|
||||
monster: MonsterDefinition,
|
||||
): HuntEncounter {
|
||||
return { ...encounter, monster };
|
||||
}
|
||||
|
||||
class FakeRepository<T extends { id: string }> {
|
||||
constructor(
|
||||
private readonly state: FakeState,
|
||||
@@ -56,10 +70,24 @@ class FakeRepository<T extends { id: string }> {
|
||||
);
|
||||
}
|
||||
|
||||
find(options: { where: Partial<T> }): Promise<T[]> {
|
||||
return Promise.resolve(
|
||||
this.rows().filter((row) => this.matches(row, options.where)),
|
||||
find(options: {
|
||||
where: Partial<T>;
|
||||
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||
}): Promise<T[]> {
|
||||
const matched = this.rows().filter((row) =>
|
||||
this.matches(row, options.where),
|
||||
);
|
||||
const orderKey = options.order
|
||||
? (Object.keys(options.order)[0] as keyof T)
|
||||
: undefined;
|
||||
if (orderKey) {
|
||||
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
|
||||
matched.sort((a, b) => {
|
||||
if (a[orderKey] === b[orderKey]) return 0;
|
||||
return a[orderKey] > b[orderKey] ? direction : -direction;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(matched);
|
||||
}
|
||||
|
||||
create(values: Partial<T>): T {
|
||||
@@ -167,7 +195,7 @@ function huntingLocation(): LocationDefinition {
|
||||
return {
|
||||
id: HUNTING_LOCATION_ID,
|
||||
key: 'burned-road',
|
||||
name: 'Verbrannte Strasse',
|
||||
name: 'Burned Road',
|
||||
description: 'A burned road.',
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
@@ -188,7 +216,7 @@ function safeLocation(): LocationDefinition {
|
||||
return {
|
||||
id: SAFE_LOCATION_ID,
|
||||
key: 'south-gate',
|
||||
name: 'Suedtor von Graufurt',
|
||||
name: 'Graufurt South Gate',
|
||||
description: 'A safe gate.',
|
||||
regionKey: 'ashen-fields',
|
||||
minRecommendedLevel: 1,
|
||||
@@ -209,8 +237,8 @@ function character(currentLocation: LocationDefinition): Character {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
renown: 1,
|
||||
silver: 0,
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
@@ -235,9 +263,8 @@ function monsterDefinition(
|
||||
maxHp: 20,
|
||||
attack: 3,
|
||||
armor: 0,
|
||||
experienceReward: 10,
|
||||
silverMin: 1,
|
||||
silverMax: 3,
|
||||
flavorText: null,
|
||||
abilities: {},
|
||||
artworkPath: `/assets/monsters/${key}.webp`,
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
@@ -251,18 +278,63 @@ function locationMonster(
|
||||
monster: MonsterDefinition,
|
||||
weight: number,
|
||||
enabled = true,
|
||||
encounterType: EncounterType = EncounterType.NORMAL,
|
||||
): LocationMonster {
|
||||
return {
|
||||
id,
|
||||
locationId,
|
||||
monsterId: monster.id,
|
||||
weight,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
encounterType,
|
||||
enabled,
|
||||
monster,
|
||||
} as LocationMonster;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real Burned Road pool (spec §3): four distinct monsters, the Charred
|
||||
* Raider rare at weight 2 out of 100.
|
||||
*/
|
||||
function burnedRoadPool(): LocationMonster[] {
|
||||
return [
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_A_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat'),
|
||||
50,
|
||||
),
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_C_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
monsterDefinition(MONSTER_C_ID, 'wild-road-dog', 'Feral Road Hound'),
|
||||
30,
|
||||
),
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_B_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
monsterDefinition(MONSTER_B_ID, 'road-bandit', 'Road Bandit'),
|
||||
18,
|
||||
),
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_D_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
monsterDefinition(MONSTER_D_ID, 'charred-looter', 'Charred Raider'),
|
||||
2,
|
||||
true,
|
||||
EncounterType.RARE,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Puts the character on the hunting ground with the given encounter pool. */
|
||||
function stateAtHuntingGround(pool: LocationMonster[]): FakeState {
|
||||
const state = createState();
|
||||
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||
state.characters[0].currentLocation = huntingLocation();
|
||||
state.locationMonsters = pool;
|
||||
return state;
|
||||
}
|
||||
|
||||
function createState(): FakeState {
|
||||
return {
|
||||
characters: [character(safeLocation())],
|
||||
@@ -341,23 +413,7 @@ describe('HuntingService', () => {
|
||||
});
|
||||
|
||||
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 state = stateAtHuntingGround(burnedRoadPool());
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||
@@ -371,7 +427,7 @@ describe('HuntingService', () => {
|
||||
expect(result.location).toEqual({
|
||||
id: HUNTING_LOCATION_ID,
|
||||
key: 'burned-road',
|
||||
name: 'Verbrannte Strasse',
|
||||
name: 'Burned Road',
|
||||
});
|
||||
expect(dataSource.state.hunts).toHaveLength(1);
|
||||
expect(dataSource.state.hunts[0]).toMatchObject({
|
||||
@@ -421,52 +477,109 @@ describe('HuntingService', () => {
|
||||
});
|
||||
|
||||
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 state = stateAtHuntingGround(burnedRoadPool());
|
||||
// Weights 50/30/18/2. Each pick is removed from the pool before the next
|
||||
// roll, so the totals shrink: 0.1*100=10 -> Ash Rat (<50); the hound and
|
||||
// the bandit remain in front of the rare, so 0.1*50=5 -> Feral Road Hound
|
||||
// and 0.1*20=2 -> Road Bandit.
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(result.encounters.map((e) => e.monster.key)).toEqual([
|
||||
'aschenratte',
|
||||
'strassenraeuber',
|
||||
'aschenratte',
|
||||
'ash-rat',
|
||||
'wild-road-dog',
|
||||
'road-bandit',
|
||||
]);
|
||||
const persisted = [...dataSource.state.huntEncounters].sort(
|
||||
(a, b) => a.position - b.position,
|
||||
);
|
||||
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
|
||||
MONSTER_A_ID,
|
||||
MONSTER_C_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',
|
||||
it('rolls the rare Charred Raider from a canned roll inside its 2% band', async () => {
|
||||
const state = stateAtHuntingGround(burnedRoadPool());
|
||||
// 0.99*100 = 99, past the 98 the three common entries cover, so the last
|
||||
// 2 points of weight -- the rare -- take the first card.
|
||||
const { service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(result.encounters[0].monster.key).toBe('charred-looter');
|
||||
expect(result.encounters[0].encounterType).toBe(EncounterType.RARE);
|
||||
// The common entries stay NORMAL, so the card only marks the real find.
|
||||
expect(result.encounters.slice(1).map((e) => e.encounterType)).toEqual([
|
||||
EncounterType.NORMAL,
|
||||
EncounterType.NORMAL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('never puts the same monster on two cards of one hunt', async () => {
|
||||
const state = stateAtHuntingGround(burnedRoadPool());
|
||||
// Every roll aims at the lowest band, which without removal would return
|
||||
// the Ash Rat three times over.
|
||||
const { service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0, 0, 0]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
const keys = result.encounters.map((e) => e.monster.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('returns fewer encounters when the pool has fewer distinct monsters', async () => {
|
||||
const state = stateAtHuntingGround(burnedRoadPool().slice(0, 2));
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
// "2-3 distinct encounter records where possible" (spec §3): a
|
||||
// two-monster pool yields two cards rather than repeating one.
|
||||
expect(result.encounters).toHaveLength(2);
|
||||
expect(dataSource.state.huntEncounters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes the monster flavor text through to the encounter', async () => {
|
||||
const monsterA = monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat', {
|
||||
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
|
||||
});
|
||||
const state = stateAtHuntingGround([
|
||||
locationMonster(
|
||||
LOCATION_MONSTER_A_ID,
|
||||
HUNTING_LOCATION_ID,
|
||||
monsterA,
|
||||
100,
|
||||
),
|
||||
]);
|
||||
const { service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1]),
|
||||
});
|
||||
|
||||
const result = await service.startHunt(CHARACTER_ID);
|
||||
|
||||
expect(result.encounters[0].monster.flavorText).toBe(
|
||||
'Scrawny and quick, it feeds on whatever the fires left.',
|
||||
);
|
||||
});
|
||||
|
||||
it('supersedes the previous active hunt when a new hunt is started', async () => {
|
||||
const monsterA = monsterDefinition(MONSTER_A_ID, 'aschenratte', 'Ash Rat');
|
||||
const state = createState();
|
||||
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
|
||||
state.characters[0].currentLocation = huntingLocation();
|
||||
@@ -498,26 +611,10 @@ describe('HuntingService', () => {
|
||||
});
|
||||
|
||||
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 state = stateAtHuntingGround(burnedRoadPool());
|
||||
const { dataSource, service } = createService({
|
||||
state,
|
||||
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
|
||||
randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
|
||||
});
|
||||
|
||||
await service.startHunt(CHARACTER_ID);
|
||||
@@ -527,16 +624,16 @@ describe('HuntingService', () => {
|
||||
);
|
||||
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);
|
||||
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_D_ID);
|
||||
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_A_ID);
|
||||
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_C_ID);
|
||||
});
|
||||
|
||||
it('computes a danger rating per encounter from the real monster stats', async () => {
|
||||
const weakMonster = monsterDefinition(
|
||||
MONSTER_A_ID,
|
||||
'aschenratte',
|
||||
'Aschenratte',
|
||||
'Ash Rat',
|
||||
{
|
||||
attack: 1,
|
||||
armor: 0,
|
||||
@@ -567,4 +664,120 @@ describe('HuntingService', () => {
|
||||
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks every freshly rolled encounter as AVAILABLE', async () => {
|
||||
const state = stateAtHuntingGround(burnedRoadPool());
|
||||
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',
|
||||
'Ash Rat',
|
||||
);
|
||||
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: 'Burned Road',
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } 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 type { LocationSummary } from '../travel/travel.service';
|
||||
@@ -9,6 +10,7 @@ import { TravelStatus } from '../travel/travel-status.enum';
|
||||
import { calculateDangerRating, DangerRating } from './danger-rating';
|
||||
import { Hunt } from './entities/hunt.entity';
|
||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
||||
import { HuntStatus } from './hunt-status.enum';
|
||||
import {
|
||||
characterNotFound,
|
||||
@@ -16,20 +18,25 @@ import {
|
||||
huntingNotAvailable,
|
||||
noHuntEncountersAvailable,
|
||||
} from './hunting.errors';
|
||||
import { RANDOM_SOURCE } from './random-source';
|
||||
import type { RandomSource } from './random-source';
|
||||
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
|
||||
export interface MonsterSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
level: number;
|
||||
artworkPath: string;
|
||||
/** Short atmosphere line for the encounter card; null when unauthored. */
|
||||
flavorText: string | null;
|
||||
}
|
||||
|
||||
export interface HuntEncounterDto {
|
||||
id: string;
|
||||
monster: MonsterSummary;
|
||||
dangerRating: DangerRating;
|
||||
status: HuntEncounterStatus;
|
||||
/** Lets the card mark a rare find without knowing any monster keys. */
|
||||
encounterType: EncounterType;
|
||||
}
|
||||
|
||||
export interface HuntResultDto {
|
||||
@@ -38,7 +45,9 @@ export interface HuntResultDto {
|
||||
encounters: HuntEncounterDto[];
|
||||
}
|
||||
|
||||
const ENCOUNTER_COUNT = 3;
|
||||
// Upper bound only: a pool with fewer distinct monsters yields fewer cards
|
||||
// (spec §3, "2-3 distinct encounter records where possible").
|
||||
const MAX_ENCOUNTER_COUNT = 3;
|
||||
|
||||
@Injectable()
|
||||
export class HuntingService {
|
||||
@@ -101,37 +110,27 @@ export class HuntingService {
|
||||
});
|
||||
await txHunts.save(hunt);
|
||||
|
||||
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT);
|
||||
const picked = this.rollEncounters(pool, MAX_ENCOUNTER_COUNT);
|
||||
|
||||
const encounterDtos: HuntEncounterDto[] = [];
|
||||
for (let position = 0; position < pickedMonsters.length; position += 1) {
|
||||
const monster = pickedMonsters[position];
|
||||
for (let position = 0; position < picked.length; position += 1) {
|
||||
const entry = picked[position];
|
||||
const encounter = txEncounters.create({
|
||||
huntId: hunt.id,
|
||||
monsterDefinitionId: monster.id,
|
||||
monsterDefinitionId: entry.monster.id,
|
||||
position,
|
||||
status: HuntEncounterStatus.AVAILABLE,
|
||||
});
|
||||
await txEncounters.save(encounter);
|
||||
|
||||
const dangerRating = calculateDangerRating(
|
||||
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
|
||||
{
|
||||
attack: monster.attack,
|
||||
armor: monster.armor,
|
||||
hp: monster.maxHp,
|
||||
},
|
||||
encounterDtos.push(
|
||||
this.toEncounterDto(
|
||||
encounter,
|
||||
entry.monster,
|
||||
character,
|
||||
entry.encounterType,
|
||||
),
|
||||
);
|
||||
|
||||
encounterDtos.push({
|
||||
id: encounter.id,
|
||||
monster: {
|
||||
key: monster.key,
|
||||
name: monster.name,
|
||||
level: monster.level,
|
||||
artworkPath: monster.artworkPath,
|
||||
},
|
||||
dangerRating,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -143,31 +142,127 @@ export class HuntingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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' },
|
||||
});
|
||||
|
||||
// The encounter type belongs to the location's pool, not to the rolled
|
||||
// row, so it is re-read here rather than duplicated onto every encounter.
|
||||
const encounterTypes = await this.loadEncounterTypes(hunt.locationId);
|
||||
|
||||
return {
|
||||
id: hunt.id,
|
||||
location: this.toLocationSummary(character.currentLocation),
|
||||
encounters: encounters.map((encounter) =>
|
||||
this.toEncounterDto(
|
||||
encounter,
|
||||
encounter.monster,
|
||||
character,
|
||||
encounterTypes.get(encounter.monsterDefinitionId) ??
|
||||
EncounterType.NORMAL,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadEncounterTypes(
|
||||
locationId: string,
|
||||
): Promise<Map<string, EncounterType>> {
|
||||
const locationMonsters = this.dataSource.getRepository(LocationMonster);
|
||||
const pool = await locationMonsters.find({ where: { locationId } });
|
||||
return new Map(pool.map((entry) => [entry.monsterId, entry.encounterType]));
|
||||
}
|
||||
|
||||
private toEncounterDto(
|
||||
encounter: HuntEncounter,
|
||||
monster: MonsterDefinition,
|
||||
character: Character,
|
||||
encounterType: EncounterType,
|
||||
): HuntEncounterDto {
|
||||
return {
|
||||
id: encounter.id,
|
||||
monster: {
|
||||
key: monster.key,
|
||||
name: monster.name,
|
||||
level: monster.level,
|
||||
artworkPath: monster.artworkPath,
|
||||
flavorText: monster.flavorText ?? null,
|
||||
},
|
||||
dangerRating: calculateDangerRating(
|
||||
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
|
||||
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
|
||||
),
|
||||
status: encounter.status,
|
||||
encounterType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws up to `count` *distinct* monsters from `pool` (spec §3).
|
||||
*
|
||||
* Each slot is one weighted pick over the entries still available, and the
|
||||
* winner is then removed so the same monster cannot fill two cards -- the
|
||||
* player is supposed to be choosing between different enemies, not looking
|
||||
* at the same rat three times. A pool with fewer entries than `count`
|
||||
* simply yields fewer cards ("where possible").
|
||||
*
|
||||
* The pick walks the remaining pool in the order it was supplied,
|
||||
* accumulating weight, and takes the first entry whose cumulative weight
|
||||
* exceeds the roll (roll < cumulative). Pure and deterministic given a
|
||||
* RandomSource, so canned `next()` values make even the rare encounter
|
||||
* reproducible in tests.
|
||||
*/
|
||||
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) {
|
||||
): LocationMonster[] {
|
||||
const remaining = [...pool];
|
||||
const picks: LocationMonster[] = [];
|
||||
|
||||
while (picks.length < count && remaining.length > 0) {
|
||||
const totalWeight = remaining.reduce((sum, e) => sum + e.weight, 0);
|
||||
const roll = this.randomSource.next() * totalWeight;
|
||||
let cumulative = 0;
|
||||
let picked: LocationMonster = pool[pool.length - 1];
|
||||
for (const entry of pool) {
|
||||
cumulative += entry.weight;
|
||||
let pickedIndex = remaining.length - 1;
|
||||
for (let index = 0; index < remaining.length; index += 1) {
|
||||
cumulative += remaining[index].weight;
|
||||
if (roll < cumulative) {
|
||||
picked = entry;
|
||||
pickedIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
picks.push(picked.monster);
|
||||
picks.push(remaining.splice(pickedIndex, 1)[0]);
|
||||
}
|
||||
|
||||
return picks;
|
||||
}
|
||||
|
||||
|
||||
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 {}
|
||||
104
apps/api/src/inventory/inventory.service.spec.ts
Normal file
104
apps/api/src/inventory/inventory.service.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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: 'Worn Shortsword',
|
||||
description:
|
||||
"A recruit's blade, sharpened more often than it's been swung.",
|
||||
rarity: ItemRarity.COMMON,
|
||||
type: ItemType.EQUIPMENT,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
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: 'Worn Shortsword',
|
||||
description:
|
||||
"A recruit's blade, sharpened more often than it's been swung.",
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user