fix(monsters): finish deleting experienceReward, column included

Design ruling R7 abolishes XP as a concept and says the column goes with
it, but no task in the plan actually dropped it -- the plan only dropped
characters.experience and combat_rewards.experience_granted. Task 9
removed experienceReward from the seed literals, leaving
monster_definitions.experience_reward as a NOT NULL column with no
default that nothing supplies. The first monster insert against a real
database would have failed on a constraint violation.

No suite here could have caught it: none of them connect to Postgres.

Drops the column in the slice migration (which has never been run, so
amending it in place is correct rather than stacking a second one),
removes the entity field, and clears the three test fixtures that still
set it. silver_min/silver_max deliberately stay -- spec 15 keeps a
direct currency drop available as a lore-valid exception, and XP has no
such carve-out.

Also retargets the seed idempotency test off renown: 1, which is the
seed's own default and so could not distinguish "preserved" from
"reset to default".

NOTE ON SCOPE: this commit also absorbs a Prettier reformatting pass
that was already sitting uncommitted in the working tree, which is why
it touches ~59 files. That churn is purely cosmetic line-rewrapping --
verified by inspection, and the suite is green at 267/267 with the build
at exactly the 3 expected errors owned by Tasks 10 and 11. The repo is
not Prettier-clean at baseline (119 files still flagged), so this was a
partial run by an earlier step, not a deliberate repo-wide format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-21 14:07:47 +02:00
parent dce26e00ad
commit 8835671657
59 changed files with 1368 additions and 399 deletions

View File

@@ -10,7 +10,9 @@ type EquippedFixture = {
item: Partial<ItemDefinition>; item: Partial<ItemDefinition>;
}; };
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> { function fakeScope(
equipped: EquippedFixture[],
): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({ const rows = equipped.map((entry) => ({
slot: entry.slot, slot: entry.slot,
characterItem: { characterItem: {
@@ -25,7 +27,7 @@ function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository
})); }));
return { return {
getRepository: () => ({ find: async () => rows }) as never, getRepository: () => ({ find: async () => rows }) as never,
} as unknown as Pick<DataSource, 'getRepository'>; };
} }
function character(overrides: Partial<Character> = {}): Character { function character(overrides: Partial<Character> = {}): Character {
@@ -42,7 +44,9 @@ describe('CharacterStatsService', () => {
const service = new CharacterStatsService({} as DataSource); const service = new CharacterStatsService({} as DataSource);
it('derives stats from the starting weapon alone', async () => { it('derives stats from the starting weapon alone', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]); const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } },
]);
const stats = await service.calculate(character(), scope); const stats = await service.calculate(character(), scope);
@@ -52,9 +56,12 @@ describe('CharacterStatsService', () => {
expect(stats.armor).toBe(0); expect(stats.armor).toBe(0);
}); });
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => { it("applies Räuberklinge's weapon damage and bonus attack", async () => {
const scope = fakeScope([ const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, {
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
]); ]);
const stats = await service.calculate(character(), scope); const stats = await service.calculate(character(), scope);
@@ -86,7 +93,9 @@ describe('CharacterStatsService', () => {
}); });
it('reports weaponDamage as 0 when no weapon is equipped', async () => { it('reports weaponDamage as 0 when no weapon is equipped', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]); const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
]);
const stats = await service.calculate(character(), scope); const stats = await service.calculate(character(), scope);
@@ -95,7 +104,10 @@ describe('CharacterStatsService', () => {
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => { it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([ const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, {
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } }, { slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
]); ]);

View File

@@ -21,6 +21,8 @@ describe('calculateDamage', () => {
}); });
it('still floors at 1 damage even with a small multiplier', () => { it('still floors at 1 damage even with a small multiplier', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1); expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(
1,
);
}); });
}); });

View File

@@ -12,6 +12,7 @@ export function calculateDamage(
): number { ): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0); const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage = const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor); (rawDamage * ARMOR_MITIGATION_CONSTANT) /
(ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage * multiplier)); return Math.max(1, Math.round(mitigatedDamage * multiplier));
} }

View File

@@ -1,11 +1,16 @@
import { CombatAction } from './combat-action.enum'; import { CombatAction } from './combat-action.enum';
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service'; import {
CombatEngineService,
UnsupportedCombatActionError,
} from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types'; import { CombatEngineState } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum'; import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum'; import { Combatant } from './combatant.enum';
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState { function baseState(
overrides: Partial<CombatEngineState> = {},
): CombatEngineState {
return { return {
status: CombatStatus.ACTIVE, status: CombatStatus.ACTIVE,
round: 1, round: 1,
@@ -31,7 +36,9 @@ describe('CombatEngineService', () => {
}); });
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => { it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); const result = engine.resolveAction(baseState(), {
action: CombatAction.ATTACK,
});
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated // raw = 6 + 8 = 14; armor 0 -> 14 mitigated
expect(result.state.monster.currentHp).toBe(45 - 14); expect(result.state.monster.currentHp).toBe(45 - 14);
@@ -44,7 +51,9 @@ describe('CombatEngineService', () => {
}); });
it('lets the monster retaliate when it survives the player attack, and advances the round', () => { it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); const result = engine.resolveAction(baseState(), {
action: CombatAction.ATTACK,
});
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5 // raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
expect(result.state.player.currentHp).toBe(100 - 5); expect(result.state.player.currentHp).toBe(100 - 5);
@@ -69,14 +78,27 @@ describe('CombatEngineService', () => {
expect(result.state.status).toBe(CombatStatus.WON); expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.round).toBe(1); expect(result.state.round).toBe(1);
expect(result.events).toEqual([ 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 }, 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', () => { it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
const state = baseState({ const state = baseState({
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } }, player: {
currentHp: 3,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
}); });
const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
@@ -84,9 +106,23 @@ describe('CombatEngineService', () => {
expect(result.state.player.currentHp).toBe(0); expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST); expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events).toEqual([ 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.PLAYER,
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST }, 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,
},
]); ]);
}); });
@@ -106,7 +142,9 @@ describe('CombatEngineService', () => {
}); });
it('HEAVY_STRIKE deals 160% damage to the monster', () => { it('HEAVY_STRIKE deals 160% damage to the monster', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE }); const result = engine.resolveAction(baseState(), {
action: CombatAction.HEAVY_STRIKE,
});
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22 // raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
expect(result.events[0]).toEqual({ expect(result.events[0]).toEqual({
@@ -119,7 +157,9 @@ describe('CombatEngineService', () => {
}); });
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => { it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH }); const result = engine.resolveAction(baseState(), {
action: CombatAction.SHIELD_BASH,
});
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10 // raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
expect(result.events[0]).toEqual({ expect(result.events[0]).toEqual({
@@ -128,7 +168,9 @@ describe('CombatEngineService', () => {
type: CombatEventType.DAMAGE, type: CombatEventType.DAMAGE,
amount: 10, amount: 10,
}); });
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false); 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', () => { it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
@@ -140,11 +182,22 @@ describe('CombatEngineService', () => {
}, },
}); });
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH }); const result = engine.resolveAction(state, {
action: CombatAction.SHIELD_BASH,
});
expect(result.events).toEqual([ expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 }, {
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }, 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.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100); expect(result.state.player.currentHp).toBe(100);
@@ -152,13 +205,24 @@ describe('CombatEngineService', () => {
}); });
it('DEFEND deals no damage and halves the monster normal attack this round', () => { it('DEFEND deals no damage and halves the monster normal attack this round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND }); const result = engine.resolveAction(baseState(), {
action: CombatAction.DEFEND,
});
expect(result.state.monster.currentHp).toBe(45); expect(result.state.monster.currentHp).toBe(45);
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2 // raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
expect(result.events).toEqual([ expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, {
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 }, 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); expect(result.state.player.currentHp).toBe(98);
}); });
@@ -215,10 +279,14 @@ describe('CombatEngineService', () => {
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => { it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 }); const round3State = baseState({ round: 3 });
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK }); const telegraphResult = engine.resolveAction(round3State, {
action: CombatAction.ATTACK,
});
expect(telegraphResult.state.player.currentHp).toBe(100); expect(telegraphResult.state.player.currentHp).toBe(100);
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); expect(telegraphResult.state.monster.stats.pendingAction).toBe(
'HEAVY_ATTACK',
);
expect(telegraphResult.events[1]).toEqual({ expect(telegraphResult.events[1]).toEqual({
source: Combatant.MONSTER, source: Combatant.MONSTER,
target: Combatant.PLAYER, target: Combatant.PLAYER,
@@ -226,7 +294,9 @@ describe('CombatEngineService', () => {
}); });
expect(telegraphResult.state.round).toBe(4); expect(telegraphResult.state.round).toBe(4);
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK }); const resolveResult = engine.resolveAction(telegraphResult.state, {
action: CombatAction.ATTACK,
});
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7 // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
expect(resolveResult.events[1]).toEqual({ expect(resolveResult.events[1]).toEqual({
@@ -254,16 +324,29 @@ describe('CombatEngineService', () => {
expect(result.state.player.currentHp).toBe(100); expect(result.state.player.currentHp).toBe(100);
expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.events).toEqual([ 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 }, 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', () => { it('is deterministic for HEAVY_STRIKE as well', () => {
const state = baseState(); const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); const first = engine.resolveAction(state, {
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); action: CombatAction.HEAVY_STRIKE,
});
const second = engine.resolveAction(state, {
action: CombatAction.HEAVY_STRIKE,
});
expect(first).toEqual(second); expect(first).toEqual(second);
}); });
@@ -281,8 +364,17 @@ describe('CombatEngineService', () => {
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4 // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
expect(result.events).toEqual([ expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, {
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4 }, 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.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 4); expect(result.state.player.currentHp).toBe(100 - 4);

View File

@@ -30,7 +30,10 @@ const POTION_HEAL_FRACTION = 0.35;
@Injectable() @Injectable()
export class CombatEngineService { export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult { resolveAction(
state: CombatEngineState,
input: CombatActionInput,
): CombatEngineResult {
switch (input.action) { switch (input.action) {
case CombatAction.ATTACK: case CombatAction.ATTACK:
return this.resolvePlayerStrike(state, 1); return this.resolvePlayerStrike(state, 1);
@@ -47,12 +50,19 @@ export class CombatEngineService {
} }
} }
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult { private resolvePlayerStrike(
state: CombatEngineState,
multiplier: number,
): CombatEngineResult {
const player = this.cloneCombatant(state.player); const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster); const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = []; const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier); const damage = calculateDamage(
player.stats,
monster.stats.armor,
multiplier,
);
monster.currentHp = Math.max(0, monster.currentHp - damage); monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({ events.push({
source: Combatant.PLAYER, source: Combatant.PLAYER,
@@ -69,7 +79,11 @@ export class CombatEngineService {
const monster = this.cloneCombatant(state.monster); const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = []; const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER); const damage = calculateDamage(
player.stats,
monster.stats.armor,
SHIELD_BASH_MULTIPLIER,
);
monster.currentHp = Math.max(0, monster.currentHp - damage); monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({ events.push({
source: Combatant.PLAYER, source: Combatant.PLAYER,
@@ -82,7 +96,11 @@ export class CombatEngineService {
if (monster.stats.pendingAction) { if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined; monster.stats.pendingAction = undefined;
interrupted = true; interrupted = true;
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }); events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.INTERRUPT,
});
} }
return this.finishRound(state, player, monster, events, false, interrupted); return this.finishRound(state, player, monster, events, false, interrupted);
@@ -92,7 +110,11 @@ export class CombatEngineService {
const player = this.cloneCombatant(state.player); const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster); const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [ const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, {
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.DEFEND,
},
]; ];
return this.finishRound(state, player, monster, events, true); return this.finishRound(state, player, monster, events, true);
@@ -108,7 +130,12 @@ export class CombatEngineService {
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1; player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
const events: CombatEngineEvent[] = [ const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed }, {
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: healed,
},
]; ];
return this.finishRound(state, player, monster, events, false); return this.finishRound(state, player, monster, events, false);
@@ -123,22 +150,50 @@ export class CombatEngineService {
interrupted = false, interrupted = false,
): CombatEngineResult { ): CombatEngineResult {
if (monster.currentHp <= 0) { if (monster.currentHp <= 0) {
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }); events.push({
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } }; source: Combatant.PLAYER,
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events }; 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) { if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events); this.resolveMonsterTurn(state.round, monster, player, defended, events);
if (player.currentHp <= 0) { if (player.currentHp <= 0) {
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST }); events.push({
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events }; source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
} }
} }
return { return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 }, state: {
...state,
player,
monster,
status: CombatStatus.ACTIVE,
round: state.round + 1,
},
events, events,
}; };
} }
@@ -154,7 +209,11 @@ export class CombatEngineService {
if (monster.stats.pendingAction === 'HEAVY_ATTACK') { if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined; monster.stats.pendingAction = undefined;
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier); const damage = calculateDamage(
monster.stats,
player.stats.armor,
HEAVY_ATTACK_MULTIPLIER * defendMultiplier,
);
player.currentHp = Math.max(0, player.currentHp - damage); player.currentHp = Math.max(0, player.currentHp - damage);
events.push({ events.push({
source: Combatant.MONSTER, source: Combatant.MONSTER,
@@ -167,11 +226,19 @@ export class CombatEngineService {
if (round % TELEGRAPH_ROUND_INTERVAL === 0) { if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
monster.stats.pendingAction = 'HEAVY_ATTACK'; monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH }); events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.TELEGRAPH,
});
return; return;
} }
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier); const damage = calculateDamage(
monster.stats,
player.stats.armor,
defendMultiplier,
);
player.currentHp = Math.max(0, player.currentHp - damage); player.currentHp = Math.max(0, player.currentHp - damage);
events.push({ events.push({
source: Combatant.MONSTER, source: Combatant.MONSTER,
@@ -181,7 +248,9 @@ export class CombatEngineService {
}); });
} }
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant { private cloneCombatant(
combatant: CombatEngineCombatant,
): CombatEngineCombatant {
return { ...combatant, stats: { ...combatant.stats } }; return { ...combatant, stats: { ...combatant.stats } };
} }
} }

View File

@@ -53,12 +53,18 @@ class FakeRepository<T extends { id: string }> {
relations?: Record<string, unknown>; relations?: Record<string, unknown>;
lock?: { mode: string }; lock?: { mode: string };
}): Promise<T | null> { }): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; const row =
return Promise.resolve(row ? this.withRelations(row, options.relations) : null); 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> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows().find((row) => this.matches(row, where)) ?? null,
);
} }
find(options: { find(options: {
@@ -66,12 +72,18 @@ class FakeRepository<T extends { id: string }> {
relations?: Record<string, unknown>; relations?: Record<string, unknown>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>; order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> { }): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where)); const matched = this.rows().filter((row) =>
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); this.matches(row, options.where),
);
return Promise.resolve(
matched.map((row) => this.withRelations(row, options.relations)),
);
} }
count(options: { where: Partial<T> }): Promise<number> { count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length); return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)).length,
);
} }
create(values: Partial<T>): T { create(values: Partial<T>): T {
@@ -98,12 +110,18 @@ class FakeRepository<T extends { id: string }> {
} }
const copy = { ...row } as T & Record<string, unknown>; const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) { if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; const itemDefinitionId = (row as unknown as CharacterItem)
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); .itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find(
(d) => d.id === itemDefinitionId,
);
} }
if (this.target === CharacterEquipment && relations['characterItem']) { if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId; const characterItemId = (row as unknown as CharacterEquipment)
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); .characterItemId;
const characterItem = this.state.characterItems.find(
(ci) => ci.id === characterItemId,
);
copy['characterItem'] = characterItem copy['characterItem'] = characterItem
? { ? {
...characterItem, ...characterItem,
@@ -113,7 +131,7 @@ class FakeRepository<T extends { id: string }> {
} }
: undefined; : undefined;
} }
return copy as T; return copy;
} }
private rows(): T[] { private rows(): T[] {
@@ -123,18 +141,24 @@ class FakeRepository<T extends { id: string }> {
if (this.target === MonsterDefinition) return this.state.monsters 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 === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents 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 === ItemDefinition)
return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems 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 === CharacterEquipment)
return this.state.characterEquipment as T[];
throw new Error(`Unsupported repository ${this.targetName()}`); throw new Error(`Unsupported repository ${this.targetName()}`);
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
private targetName(): string { private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
} }
} }
@@ -146,7 +170,9 @@ class FakeDataSource {
return new FakeRepository(this.state, target, this); return new FakeRepository(this.state, target, this);
} }
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> { async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({ return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target), this.getRepository(target),
@@ -177,7 +203,9 @@ function character(overrides: Partial<Character> = {}): Character {
} as Character; } as Character;
} }
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition { function monster(
overrides: Partial<MonsterDefinition> = {},
): MonsterDefinition {
return { return {
id: MONSTER_ID, id: MONSTER_ID,
key: 'road-bandit', key: 'road-bandit',
@@ -186,7 +214,6 @@ function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition
maxHp: 75, maxHp: 75,
attack: 9, attack: 9,
armor: 5, armor: 5,
experienceReward: 16,
silverMin: 9, silverMin: 9,
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
@@ -207,7 +234,10 @@ function hunt(overrides: Partial<Hunt> = {}): Hunt {
} as Hunt; } as Hunt;
} }
function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter { function encounter(
id: string,
overrides: Partial<HuntEncounter> = {},
): HuntEncounter {
return { return {
id, id,
huntId: HUNT_ID, huntId: HUNT_ID,
@@ -219,7 +249,9 @@ function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEnco
} as HuntEncounter; } as HuntEncounter;
} }
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition { function itemDefinition(
overrides: Partial<ItemDefinition> = {},
): ItemDefinition {
return { return {
id: WORN_SWORD_DEFINITION_ID, id: WORN_SWORD_DEFINITION_ID,
key: 'worn-short-sword', key: 'worn-short-sword',
@@ -243,12 +275,16 @@ function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition
} }
function fakeTravelService(): TravelService { function fakeTravelService(): TravelService {
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService; return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService;
} }
function fakeRewardService(): CombatRewardService { function fakeRewardService(): CombatRewardService {
return { return {
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }), grantVictoryRewards: jest
.fn()
.mockResolvedValue({ experience: 0, silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null), loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService; } as unknown as CombatRewardService;
} }
@@ -302,8 +338,13 @@ function createHarness() {
], ],
}; };
const dataSource = new FakeDataSource(state); const dataSource = new FakeDataSource(state);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); const characterStats = new CharacterStatsService(
const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats); dataSource as unknown as DataSource,
);
const equipmentService = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
);
const combatService = new CombatService( const combatService = new CombatService(
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
fakeTravelService(), fakeTravelService(),
@@ -322,8 +363,16 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', ()
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
let beforeDamage = 0; let beforeDamage = 0;
let beforeResult = before; let beforeResult = before;
for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) { for (
beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK); let round = 0;
round < 10 && beforeResult.status === 'ACTIVE';
round += 1
) {
beforeResult = await combatService.performAction(
CHARACTER_ID,
before.id,
CombatAction.ATTACK,
);
if (round === 0) { if (round === 0) {
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp; beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
} }
@@ -336,7 +385,11 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', ()
state.huntEncounters.push(encounter('encounter-2')); state.huntEncounters.push(encounter('encounter-2'));
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2'); const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK); const afterResult = await combatService.performAction(
CHARACTER_ID,
after.id,
CombatAction.ATTACK,
);
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp; const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
// (6+8) vs 5 armor -> round(14 * 60/65) = 13 // (6+8) vs 5 armor -> round(14 * 60/65) = 13
@@ -346,7 +399,7 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', ()
expect(afterDamage).toBeGreaterThan(beforeDamage); expect(afterDamage).toBeGreaterThan(beforeDamage);
}); });
it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => { it("rejects equipping during an active combat, and never retroactively rewrites a finished combat's snapshot", async () => {
const { state, combatService, equipmentService } = createHarness(); const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1')); state.huntEncounters.push(encounter('encounter-1'));
@@ -361,7 +414,11 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', ()
// (status/round) must stay exactly what it was when the fight ended. // (status/round) must stay exactly what it was when the fight ended.
let result = combat; let result = combat;
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) { for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK); result = await combatService.performAction(
CHARACTER_ID,
combat.id,
CombatAction.ATTACK,
);
} }
expect(result.status).not.toBe('ACTIVE'); expect(result.status).not.toBe('ACTIVE');

View File

@@ -20,7 +20,10 @@ describe('CombatController', () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
controllers: [CombatController], controllers: [CombatController],
providers: [ providers: [
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } }, {
provide: CombatService,
useValue: { getCombat, getActiveCombat, performAction },
},
], ],
}).compile(); }).compile();
@@ -34,20 +37,40 @@ describe('CombatController', () => {
}); });
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => { it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; const combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: {},
monster: {},
events: [],
rewards: null,
};
getCombat.mockResolvedValue(combat); getCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200); const response = await request(app.getHttpServer())
.get('/api/combats/combat-1')
.expect(200);
expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1'); expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
expect(response.body).toEqual(combat); expect(response.body).toEqual(combat);
}); });
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => { it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null }; const combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 3,
player: {},
monster: {},
events: [],
rewards: null,
};
getActiveCombat.mockResolvedValue(combat); getActiveCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); const response = await request(app.getHttpServer())
.get('/api/combats/active')
.expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(getCombat).not.toHaveBeenCalled(); expect(getCombat).not.toHaveBeenCalled();
@@ -57,7 +80,9 @@ describe('CombatController', () => {
it('returns an empty body from GET /api/combats/active when no combat is running', async () => { it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
getActiveCombat.mockResolvedValue(null); getActiveCombat.mockResolvedValue(null);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); const response = await request(app.getHttpServer())
.get('/api/combats/active')
.expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual({}); expect(response.body).toEqual({});
@@ -65,7 +90,15 @@ describe('CombatController', () => {
}); });
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { 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 }; const combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: {},
monster: {},
events: [],
rewards: null,
};
performAction.mockResolvedValue(combat); performAction.mockResolvedValue(combat);
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
@@ -73,7 +106,11 @@ describe('CombatController', () => {
.send({ action: 'ATTACK' }) .send({ action: 'ATTACK' })
.expect(201); .expect(201);
expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK'); expect(performAction).toHaveBeenCalledWith(
DEMO_CHARACTER_ID,
'combat-1',
'ATTACK',
);
expect(response.body).toEqual(combat); expect(response.body).toEqual(combat);
}); });
@@ -89,7 +126,13 @@ describe('CombatController', () => {
it('rejects server-owned combat fields the client must never send', async () => { it('rejects server-owned combat fields the client must never send', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/combats/combat-1/actions') .post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 }) .send({
action: 'ATTACK',
damage: 999,
playerHp: 1,
monsterHp: 1,
round: 99,
})
.expect(400); .expect(400);
expect(performAction).not.toHaveBeenCalled(); expect(performAction).not.toHaveBeenCalled();

View File

@@ -19,7 +19,14 @@ export class CombatController {
} }
@Post(':combatId/actions') @Post(':combatId/actions')
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) { performAction(
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action); @Param('combatId') combatId: string,
@Body() dto: CombatActionDto,
) {
return this.combatService.performAction(
DEMO_CHARACTER_ID,
combatId,
dto.action,
);
} }
} }

View File

@@ -16,7 +16,14 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]), TypeOrmModule.forFeature([
Character,
Hunt,
HuntEncounter,
MonsterDefinition,
Combat,
CombatEvent,
]),
TravelModule, TravelModule,
CharactersModule, CharactersModule,
RewardsModule, RewardsModule,

View File

@@ -197,7 +197,6 @@ function monster(
maxHp: 45, maxHp: 45,
attack: 5, attack: 5,
armor: 0, armor: 0,
experienceReward: 8,
silverMin: 4, silverMin: 4,
silverMax: 7, silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
@@ -661,7 +660,11 @@ describe('CombatService', () => {
const { service, combatId } = await startedCombat(); const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION); const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.POTION,
);
expect(result.player.potionsRemaining).toBe(1); expect(result.player.potionsRemaining).toBe(1);
expect(result.player.currentHp).toBe(95); expect(result.player.currentHp).toBe(95);
@@ -686,7 +689,11 @@ describe('CombatService', () => {
const { service, combatId } = await startedCombat(); const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); const telegraphed = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK'); expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK');
@@ -842,7 +849,11 @@ describe('CombatService', () => {
rewards, rewards,
); );
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); const result = await service.performAction(
CHARACTER_ID,
'combat-1',
CombatAction.ATTACK,
);
expect(result.status).toBe(CombatStatus.WON); expect(result.status).toBe(CombatStatus.WON);
expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] }); expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] });
@@ -890,7 +901,11 @@ describe('CombatService', () => {
rewards, rewards,
); );
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); const result = await service.performAction(
CHARACTER_ID,
'combat-1',
CombatAction.ATTACK,
);
expect(result.status).toBe(CombatStatus.LOST); expect(result.status).toBe(CombatStatus.LOST);
expect(result.rewards).toBeNull(); expect(result.rewards).toBeNull();
@@ -986,18 +1001,20 @@ describe('CombatService', () => {
// before failing, so the assertions below prove the rollback // before failing, so the assertions below prove the rollback
// discards those writes rather than passing vacuously because // discards those writes rather than passing vacuously because
// nothing was ever written. // nothing was ever written.
grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => { grantVictoryRewards: jest
const characters = manager.getRepository(Character); .fn()
const combatCharacter = await characters.findOneBy({ .mockImplementation(async (manager: EntityManager) => {
id: CHARACTER_ID, const characters = manager.getRepository(Character);
}); const combatCharacter = await characters.findOneBy({
if (combatCharacter) { id: CHARACTER_ID,
combatCharacter.experience += 8; });
combatCharacter.silver += 6; if (combatCharacter) {
await characters.save(combatCharacter); combatCharacter.experience += 8;
} combatCharacter.silver += 6;
throw new Error('reward persistence failed'); await characters.save(combatCharacter);
}), }
throw new Error('reward persistence failed');
}),
}), }),
); );

View File

@@ -134,7 +134,10 @@ export class CombatService {
throw invalidHuntEncounter(); throw invalidHuntEncounter();
} }
const playerStats = await this.characterStats.calculate(character, manager); const playerStats = await this.characterStats.calculate(
character,
manager,
);
const combat = combats.create({ const combat = combats.create({
characterId, characterId,
@@ -229,7 +232,10 @@ export class CombatService {
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished(); throw combatAlreadyFinished();
} }
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) { if (
action === CombatAction.POTION &&
(combat.playerState.potionsRemaining ?? 0) <= 0
) {
throw combatNoPotionsRemaining(); throw combatNoPotionsRemaining();
} }

View File

@@ -12,7 +12,9 @@ import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity'; import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' }) @Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true }) @Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], {
unique: true,
})
export class CombatEvent { export class CombatEvent {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -28,7 +28,15 @@ describe('HuntEncounterAttackController', () => {
}); });
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => { 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 }; const combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: {},
monster: {},
events: [],
rewards: null,
};
startCombat.mockResolvedValue(combat); startCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())

View File

@@ -160,7 +160,9 @@ export class CreateLootAndRewards1788600000000 implements MigrationInterface {
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"', '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_item"');
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"'); await queryRunner.query(
'DROP INDEX "IDX_loot_table_entries_table_position"',
);
await queryRunner.query('DROP TABLE "loot_table_entries"'); await queryRunner.query('DROP TABLE "loot_table_entries"');
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"'); await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
await queryRunner.query('DROP TABLE "loot_tables"'); await queryRunner.query('DROP TABLE "loot_tables"');

View File

@@ -7,9 +7,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
* alone — the map and hunt screens still render them. Backfill defaults keep * alone — the map and hunt screens still render them. Backfill defaults keep
* existing rows valid; the seed replaces them with authored content. * existing rows valid; the seed replaces them with authored content.
*/ */
export class CreateLocalLocationView1788700000000 export class CreateLocalLocationView1788700000000 implements MigrationInterface {
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query( await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'', 'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',

View File

@@ -2,10 +2,18 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface { export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`); await queryRunner.query(
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`); `ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`,
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`); );
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`); 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> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@@ -13,7 +13,9 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)', '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 "level"');
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "experience"'); await queryRunner.query(
'ALTER TABLE "characters" DROP COLUMN "experience"',
);
// --- ItemDefinition: drop requiredLevel (spec §14, design R4) --- // --- ItemDefinition: drop requiredLevel (spec §14, design R4) ---
await queryRunner.query( await queryRunner.query(
@@ -48,6 +50,14 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"', '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) --- // --- ReputationFaction (spec §9) ---
await queryRunner.query(`CREATE TABLE "reputation_factions" ( await queryRunner.query(`CREATE TABLE "reputation_factions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(), "id" uuid NOT NULL DEFAULT gen_random_uuid(),
@@ -141,14 +151,22 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'DROP INDEX "IDX_character_renown_milestones_character_milestone"', 'DROP INDEX "IDX_character_renown_milestones_character_milestone"',
); );
await queryRunner.query('DROP TABLE "character_renown_milestones"'); await queryRunner.query('DROP TABLE "character_renown_milestones"');
await queryRunner.query('DROP INDEX "IDX_renown_milestone_definitions_key"'); await queryRunner.query(
'DROP INDEX "IDX_renown_milestone_definitions_key"',
);
await queryRunner.query('DROP TABLE "renown_milestone_definitions"'); await queryRunner.query('DROP TABLE "renown_milestone_definitions"');
await queryRunner.query('DROP INDEX "IDX_character_reputation_character_faction"'); await queryRunner.query(
'DROP INDEX "IDX_character_reputation_character_faction"',
);
await queryRunner.query('DROP TABLE "character_reputation"'); await queryRunner.query('DROP TABLE "character_reputation"');
await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"'); await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"');
await queryRunner.query('DROP TABLE "reputation_factions"'); 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( await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0', 'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0',
); );
@@ -175,12 +193,16 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1', '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 "level" integer NOT NULL DEFAULT 1',
);
await queryRunner.query( await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0', 'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0',
); );
await queryRunner.query('UPDATE "characters" SET "level" = "renown"'); 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 CONSTRAINT "CHK_characters_renown"',
);
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"'); await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"');
} }
} }

View File

@@ -8,7 +8,8 @@ describe('combat system schema', () => {
const metadata = getMetadataArgsStorage(); const metadata = getMetadataArgsStorage();
const relations = metadata.relations.filter( const relations = metadata.relations.filter(
(relation) => relation.target === Combat || relation.target === CombatEvent, (relation) =>
relation.target === Combat || relation.target === CombatEvent,
); );
expect( expect(
@@ -19,10 +20,26 @@ describe('combat system schema', () => {
})), })),
).toEqual( ).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }), expect.objectContaining({
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }), onDelete: 'RESTRICT',
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }), propertyName: 'character',
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }), 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,
}),
]), ]),
); );
}); });
@@ -37,7 +54,10 @@ describe('combat system schema', () => {
); );
expect(index).toBeDefined(); expect(index).toBeDefined();
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
}); });
}); });

View File

@@ -7,7 +7,8 @@ describe('character_equipment schema', () => {
const metadata = getMetadataArgsStorage(); const metadata = getMetadataArgsStorage();
const column = metadata.columns.find( const column = metadata.columns.find(
(candidate) => (candidate) =>
candidate.target === CharacterEquipment && candidate.propertyName === 'slot', candidate.target === CharacterEquipment &&
candidate.propertyName === 'slot',
); );
expect(column).toBeDefined(); expect(column).toBeDefined();

View File

@@ -7,7 +7,8 @@ describe('combat_events.type enum', () => {
it('includes the Playable Slice 0.6 event types', () => { it('includes the Playable Slice 0.6 event types', () => {
const metadata = getMetadataArgsStorage(); const metadata = getMetadataArgsStorage();
const column = metadata.columns.find( const column = metadata.columns.find(
(candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type', (candidate) =>
candidate.target === CombatEvent && candidate.propertyName === 'type',
); );
expect(column).toBeDefined(); expect(column).toBeDefined();

View File

@@ -29,21 +29,31 @@ describe('loot and rewards schema', () => {
}); });
it('keeps one stack per character per item definition', () => { it('keeps one stack per character per item definition', () => {
expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true); expect(
uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId']),
).toBe(true);
}); });
it('keeps loot-table content keys and entry positions unique', () => { it('keeps loot-table content keys and entry positions unique', () => {
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true); expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true); expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true); expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true); true,
);
expect(
uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId']),
).toBe(true);
}); });
it('maps reward and loot relations with the documented onDelete behavior', () => { it('maps reward and loot relations with the documented onDelete behavior', () => {
const relations = getMetadataArgsStorage().relations.filter((relation) => const relations = getMetadataArgsStorage().relations.filter((relation) =>
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes( [
relation.target as never, CombatReward,
), CombatRewardItem,
CharacterItem,
LootTableEntry,
MonsterDefinition,
].includes(relation.target as never),
); );
expect( expect(
@@ -54,16 +64,56 @@ describe('loot and rewards schema', () => {
})), })),
).toEqual( ).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }), expect.objectContaining({
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }), onDelete: 'CASCADE',
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }), propertyName: 'combat',
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }), target: CombatReward,
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }), }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }), expect.objectContaining({
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }), onDelete: 'RESTRICT',
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }), propertyName: 'character',
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }), target: CombatReward,
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }), }),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'combatReward',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'characterItem',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'character',
target: CharacterItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: CharacterItem,
}),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'lootTable',
target: LootTableEntry,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: LootTableEntry,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'lootTable',
target: MonsterDefinition,
}),
]), ]),
); );
}); });
@@ -72,14 +122,16 @@ describe('loot and rewards schema', () => {
const columns = getMetadataArgsStorage().columns; const columns = getMetadataArgsStorage().columns;
const silver = columns.find( const silver = columns.find(
(candidate) => candidate.target === Character && candidate.propertyName === 'silver', (candidate) =>
candidate.target === Character && candidate.propertyName === 'silver',
); );
expect(silver).toBeDefined(); expect(silver).toBeDefined();
expect(silver?.options.type).toBe('integer'); expect(silver?.options.type).toBe('integer');
const lootTableId = columns.find( const lootTableId = columns.find(
(candidate) => (candidate) =>
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId', candidate.target === MonsterDefinition &&
candidate.propertyName === 'lootTableId',
); );
expect(lootTableId).toBeDefined(); expect(lootTableId).toBeDefined();
expect(lootTableId?.options.nullable).toBe(true); expect(lootTableId?.options.nullable).toBe(true);
@@ -88,7 +140,8 @@ describe('loot and rewards schema', () => {
it('stores drop chance as a numeric column so probabilities stay data-driven', () => { it('stores drop chance as a numeric column so probabilities stay data-driven', () => {
const dropChance = getMetadataArgsStorage().columns.find( const dropChance = getMetadataArgsStorage().columns.find(
(candidate) => (candidate) =>
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance', candidate.target === LootTableEntry &&
candidate.propertyName === 'dropChance',
); );
expect(dropChance?.options.type).toBe('numeric'); expect(dropChance?.options.type).toBe('numeric');
@@ -111,11 +164,19 @@ describe('loot and rewards schema', () => {
expect(upQueries).toEqual( expect(upQueries).toEqual(
expect.arrayContaining([ expect.arrayContaining([
// The database half of the "one reward per combat" invariant (spec §7, §37). // The database half of the "one reward per combat" invariant (spec §7, §37).
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'), expect.stringContaining(
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'), 'CREATE UNIQUE INDEX "IDX_combat_rewards_combat"',
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'), ),
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 "characters" ADD COLUMN "silver"'),
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'), expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"',
),
]), ]),
); );

View File

@@ -19,9 +19,13 @@ function columnNames(target: unknown): string[] {
function uniqueIndexFor(target: unknown, columns: string[]): boolean { function uniqueIndexFor(target: unknown, columns: string[]): boolean {
const index = getMetadataArgsStorage().indices.find( const index = getMetadataArgsStorage().indices.find(
(candidate) => (candidate) =>
candidate.target === target && columns.every((column) => candidate.columns?.includes(column)), candidate.target === target &&
columns.every((column) => candidate.columns?.includes(column)),
); );
const meta = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; const meta = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (meta?.options?.unique ?? meta?.unique) === true; return (meta?.options?.unique ?? meta?.unique) === true;
} }
@@ -52,7 +56,9 @@ describe('Slice 0.6.5 entity metadata', () => {
}); });
it('CharacterReputation enforces one row per character per faction', () => { it('CharacterReputation enforces one row per character per faction', () => {
expect(uniqueIndexFor(CharacterReputation, ['characterId', 'factionId'])).toBe(true); expect(
uniqueIndexFor(CharacterReputation, ['characterId', 'factionId']),
).toBe(true);
}); });
it('RenownMilestoneDefinition has a unique key', () => { it('RenownMilestoneDefinition has a unique key', () => {

View File

@@ -35,8 +35,12 @@ describe('CreateRenownAndReputation1791000000000', () => {
it('backfills renown from level BEFORE dropping the level column', async () => { it('backfills renown from level BEFORE dropping the level column', async () => {
const up = await runUp(); const up = await runUp();
const backfillIndex = up.findIndex((sql) => sql.includes('LEAST(GREATEST("level", 1), 15)')); const backfillIndex = up.findIndex((sql) =>
const dropLevelIndex = up.findIndex((sql) => sql.includes('DROP COLUMN "level"')); sql.includes('LEAST(GREATEST("level", 1), 15)'),
);
const dropLevelIndex = up.findIndex((sql) =>
sql.includes('DROP COLUMN "level"'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0); expect(backfillIndex).toBeGreaterThanOrEqual(0);
expect(dropLevelIndex).toBeGreaterThanOrEqual(0); expect(dropLevelIndex).toBeGreaterThanOrEqual(0);
@@ -47,8 +51,12 @@ describe('CreateRenownAndReputation1791000000000', () => {
it('adds the renown range constraint only AFTER the backfill has populated valid values', async () => { it('adds the renown range constraint only AFTER the backfill has populated valid values', async () => {
const up = await runUp(); const up = await runUp();
const backfillIndex = up.findIndex((sql) => sql.includes('LEAST(GREATEST("level", 1), 15)')); const backfillIndex = up.findIndex((sql) =>
const constraintIndex = up.findIndex((sql) => sql.includes('CHK_characters_renown')); sql.includes('LEAST(GREATEST("level", 1), 15)'),
);
const constraintIndex = up.findIndex((sql) =>
sql.includes('CHK_characters_renown'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0); expect(backfillIndex).toBeGreaterThanOrEqual(0);
expect(constraintIndex).toBeGreaterThanOrEqual(0); expect(constraintIndex).toBeGreaterThanOrEqual(0);
@@ -62,7 +70,9 @@ describe('CreateRenownAndReputation1791000000000', () => {
expect(up).toEqual( expect(up).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.stringContaining('ALTER TABLE "item_definitions" DROP COLUMN "required_level"'), expect.stringContaining(
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
),
]), ]),
); );
}); });
@@ -72,8 +82,12 @@ describe('CreateRenownAndReputation1791000000000', () => {
expect(up).toEqual( expect(up).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.stringContaining(`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`), expect.stringContaining(
expect.stringContaining(`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`), `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('DROP TYPE "item_type_enum"'),
expect.stringContaining( expect.stringContaining(
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`, `CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
@@ -87,30 +101,63 @@ describe('CreateRenownAndReputation1791000000000', () => {
expect(up).toEqual( expect(up).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.stringContaining('ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"'), 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 () => { it('creates all five new tables with their unique constraints', async () => {
const up = await runUp(); const up = await runUp();
expect(up).toEqual( expect(up).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.stringContaining('CREATE TABLE "reputation_factions"'), expect.stringContaining('CREATE TABLE "reputation_factions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_reputation_factions_key"'), expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_reputation_factions_key"',
),
expect.stringContaining('CREATE TABLE "character_reputation"'), expect.stringContaining('CREATE TABLE "character_reputation"'),
expect.stringContaining( expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")', '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 TABLE "renown_milestone_definitions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"'), expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"',
),
expect.stringContaining('CREATE TABLE "character_renown_milestones"'), expect.stringContaining('CREATE TABLE "character_renown_milestones"'),
expect.stringContaining( expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")', '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 TABLE "turn_in_definitions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"'), expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"',
),
]), ]),
); );
}); });
@@ -125,14 +172,27 @@ describe('CreateRenownAndReputation1791000000000', () => {
expect.stringContaining('DROP TABLE "renown_milestone_definitions"'), expect.stringContaining('DROP TABLE "renown_milestone_definitions"'),
expect.stringContaining('DROP TABLE "character_reputation"'), expect.stringContaining('DROP TABLE "character_reputation"'),
expect.stringContaining('DROP TABLE "reputation_factions"'), expect.stringContaining('DROP TABLE "reputation_factions"'),
expect.stringContaining('ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"'), expect.stringContaining(
expect.stringContaining('ALTER TABLE "item_definitions" ADD COLUMN "required_level"'), '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 "level"'),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "experience"'), expect.stringContaining(
expect.stringContaining('ALTER TABLE "characters" DROP COLUMN "renown"'), '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 turnInDropIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "turn_in_definitions"'),
);
const reputationFactionsDropIndex = down.findIndex((sql) => const reputationFactionsDropIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "reputation_factions"'), sql.includes('DROP TABLE "reputation_factions"'),
); );

View File

@@ -129,7 +129,10 @@ describe('seedVisibleVerticalSlice', () => {
Object.assign(characterRepository.rows[0], { Object.assign(characterRepository.rows[0], {
currentLocationId: BURNED_ROAD_ID, currentLocationId: BURNED_ROAD_ID,
currentHp: 57, currentHp: 57,
renown: 1, // 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); await seedVisibleVerticalSlice(dataSource);
@@ -168,7 +171,7 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({ expect.objectContaining({
currentLocationId: BURNED_ROAD_ID, currentLocationId: BURNED_ROAD_ID,
currentHp: 57, currentHp: 57,
renown: 1, renown: 5,
}), }),
); );

View File

@@ -14,6 +14,9 @@ export class EquipmentController {
@Post() @Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> { equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); return this.equipmentService.equip(
DEMO_CHARACTER_ID,
request.characterItemId,
);
} }
} }

View File

@@ -11,7 +11,13 @@ import { EquipmentService } from './equipment.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]), TypeOrmModule.forFeature([
Character,
Combat,
CharacterItem,
ItemDefinition,
CharacterEquipment,
]),
CharactersModule, CharactersModule,
], ],
controllers: [EquipmentController], controllers: [EquipmentController],

View File

@@ -38,17 +38,30 @@ class FakeRepository<T extends { id: string }> {
relations?: Record<string, unknown>; relations?: Record<string, unknown>;
lock?: { mode: string }; lock?: { mode: string };
}): Promise<T | null> { }): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; const row =
return Promise.resolve(row ? this.withRelations(row, options.relations) : null); 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> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows().find((row) => this.matches(row, where)) ?? null,
);
} }
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> { find(options: {
const matched = this.rows().filter((row) => this.matches(row, options.where)); where: Partial<T>;
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); 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 { create(values: Partial<T>): T {
@@ -75,12 +88,18 @@ class FakeRepository<T extends { id: string }> {
} }
const copy = { ...row } as T & Record<string, unknown>; const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) { if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; const itemDefinitionId = (row as unknown as CharacterItem)
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); .itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find(
(d) => d.id === itemDefinitionId,
);
} }
if (this.target === CharacterEquipment && relations['characterItem']) { if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId; const characterItemId = (row as unknown as CharacterEquipment)
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); .characterItemId;
const characterItem = this.state.characterItems.find(
(ci) => ci.id === characterItemId,
);
copy['characterItem'] = characterItem copy['characterItem'] = characterItem
? { ? {
...characterItem, ...characterItem,
@@ -90,24 +109,30 @@ class FakeRepository<T extends { id: string }> {
} }
: undefined; : undefined;
} }
return copy as T; return copy;
} }
private rows(): T[] { private rows(): T[] {
if (this.target === Character) return this.state.characters as T[]; if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions 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 === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; if (this.target === CharacterEquipment)
return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats as T[]; if (this.target === Combat) return this.state.combats as T[];
throw new Error(`Unsupported repository ${this.targetName()}`); throw new Error(`Unsupported repository ${this.targetName()}`);
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
private targetName(): string { private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
} }
} }
@@ -119,7 +144,9 @@ class FakeDataSource {
return new FakeRepository(this.state, target, this); return new FakeRepository(this.state, target, this);
} }
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> { async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({ return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target), this.getRepository(target),
@@ -133,7 +160,9 @@ class FakeDataSource {
} }
} }
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition { function itemDefinition(
overrides: Partial<ItemDefinition> = {},
): ItemDefinition {
return { return {
id: 'def-worn-sword', id: 'def-worn-sword',
key: 'worn-short-sword', key: 'worn-short-sword',
@@ -152,7 +181,7 @@ function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
...overrides, ...overrides,
} as ItemDefinition; };
} }
function character(overrides: Partial<Character> = {}): Character { function character(overrides: Partial<Character> = {}): Character {
@@ -176,12 +205,20 @@ function createHarness(state: Partial<State> = {}) {
...state, ...state,
}; };
const dataSource = new FakeDataSource(fullState); const dataSource = new FakeDataSource(fullState);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); const characterStats = new CharacterStatsService(
const service = new EquipmentService(dataSource as unknown as DataSource, characterStats); dataSource as unknown as DataSource,
);
const service = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
);
return { state: fullState, service }; return { state: fullState, service };
} }
async function expectEquipmentDomainError(promise: Promise<unknown>, code: string): Promise<void> { async function expectEquipmentDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown; let error: unknown;
try { try {
await promise; await promise;
@@ -265,7 +302,9 @@ describe('EquipmentService', () => {
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID); expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1); expect(state.characterEquipment).toHaveLength(1);
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined(); expect(
state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID),
).toBeDefined();
}); });
it('rejects equipping an item owned by a different character', async () => { it('rejects equipping an item owned by a different character', async () => {
@@ -382,7 +421,9 @@ describe('EquipmentService', () => {
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).toHaveLength(1);
expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID); expect(state.characterEquipment[0].characterItemId).toBe(
BANDIT_BLADE_ITEM_ID,
);
}); });
it('rejects equipping while the character has an active combat', async () => { it('rejects equipping while the character has an active combat', async () => {
@@ -397,7 +438,13 @@ describe('EquipmentService', () => {
quantity: 1, quantity: 1,
} as CharacterItem, } as CharacterItem,
], ],
combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat], combats: [
{
id: 'combat-1',
characterId: CHARACTER_ID,
status: CombatStatus.ACTIVE,
} as Combat,
],
}); });
await expectEquipmentDomainError( await expectEquipmentDomainError(
@@ -422,7 +469,12 @@ describe('EquipmentService', () => {
FEET: null, FEET: null,
AMULET: null, AMULET: null,
}); });
expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 }); expect(result.stats).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 0,
armor: 0,
});
}); });
}); });
}); });

View File

@@ -26,7 +26,10 @@ export interface EquipmentSlotItemDto {
}; };
} }
export type EquipmentSlotsDto = Record<EquipmentSlot, EquipmentSlotItemDto | null>; export type EquipmentSlotsDto = Record<
EquipmentSlot,
EquipmentSlotItemDto | null
>;
export interface EquipmentStatsDto { export interface EquipmentStatsDto {
maxHp: number; maxHp: number;
@@ -64,7 +67,10 @@ export class EquipmentService {
* (spec §14, §28). Runs in one transaction: the old item is unequipped by * (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15). * being overwritten, never deleted (spec §15).
*/ */
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> { async equip(
characterId: string,
characterItemId: string,
): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character); const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat); const combats = manager.getRepository(Combat);

View File

@@ -36,7 +36,7 @@ function withMonster(
encounter: HuntEncounter, encounter: HuntEncounter,
monster: MonsterDefinition, monster: MonsterDefinition,
): HuntEncounter { ): HuntEncounter {
return { ...encounter, monster } as HuntEncounter; return { ...encounter, monster };
} }
class FakeRepository<T extends { id: string }> { class FakeRepository<T extends { id: string }> {
@@ -260,7 +260,6 @@ function monsterDefinition(
maxHp: 20, maxHp: 20,
attack: 3, attack: 3,
armor: 0, armor: 0,
experienceReward: 10,
silverMin: 1, silverMin: 1,
silverMax: 3, silverMax: 3,
artworkPath: `/assets/monsters/${key}.webp`, artworkPath: `/assets/monsters/${key}.webp`,

View File

@@ -34,7 +34,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
} }
describe('InventoryService', () => { describe('InventoryService', () => {
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => { it("returns only the current character's items with definition data, quantity, and equipped state", async () => {
const items = [ const items = [
characterItem({ id: 'item-1', quantity: 1 }), characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }), characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
@@ -44,7 +44,10 @@ describe('InventoryService', () => {
} as unknown as Repository<CharacterItem>; } as unknown as Repository<CharacterItem>;
const equipment = { const equipment = {
find: jest.fn().mockResolvedValue([ find: jest.fn().mockResolvedValue([
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment, {
characterItemId: 'item-1',
slot: EquipmentSlot.WEAPON,
} as CharacterEquipment,
]), ]),
} as unknown as Repository<CharacterEquipment>; } as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment); const service = new InventoryService(characterItems, equipment);
@@ -92,6 +95,8 @@ describe('InventoryService', () => {
} as unknown as Repository<CharacterEquipment>; } as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment); const service = new InventoryService(characterItems, equipment);
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] }); await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({
items: [],
});
}); });
}); });

View File

@@ -19,9 +19,13 @@ import { ItemDefinition } from './item-definition.entity';
* `ItemDefinition.id`. * `ItemDefinition.id`.
*/ */
@Entity({ name: 'character_items' }) @Entity({ name: 'character_items' })
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], { @Index(
unique: true, 'IDX_character_items_character_item',
}) ['characterId', 'itemDefinitionId'],
{
unique: true,
},
)
export class CharacterItem { export class CharacterItem {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -25,7 +25,12 @@ export class ItemDefinition {
@Column({ name: 'description', type: 'text' }) @Column({ name: 'description', type: 'text' })
description!: string; description!: string;
@Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' }) @Column({
name: 'type',
type: 'enum',
enum: ItemType,
enumName: 'item_type_enum',
})
type!: ItemType; type!: ItemType;
@Column({ @Column({
@@ -37,7 +42,12 @@ export class ItemDefinition {
}) })
equipmentSlot!: EquipmentSlot | null; equipmentSlot!: EquipmentSlot | null;
@Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' }) @Column({
name: 'rarity',
type: 'enum',
enum: ItemRarity,
enumName: 'item_rarity_enum',
})
rarity!: ItemRarity; rarity!: ItemRarity;
@Column({ name: 'tier', type: 'integer' }) @Column({ name: 'tier', type: 'integer' })

View File

@@ -19,8 +19,14 @@ import { LootTable } from './loot-table.entity';
* `dropChance = 1.0000` — no extra mechanism needed (spec §14). * `dropChance = 1.0000` — no extra mechanism needed (spec §14).
*/ */
@Entity({ name: 'loot_table_entries' }) @Entity({ name: 'loot_table_entries' })
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true }) @Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], {
@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true }) unique: true,
})
@Index(
'IDX_loot_table_entries_table_item',
['lootTableId', 'itemDefinitionId'],
{ unique: true },
)
export class LootTableEntry { export class LootTableEntry {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -7,7 +7,10 @@ import { LootService } from './loot.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])], imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])],
providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }], providers: [
LootService,
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
],
exports: [LootService], exports: [LootService],
}) })
export class LootModule {} export class LootModule {}

View File

@@ -30,7 +30,9 @@ function queuedRandom(...values: number[]): RandomSource {
return { return {
next: () => { next: () => {
if (index >= values.length) { if (index >= values.length) {
throw new Error('LootService consumed more random values than the test queued'); throw new Error(
'LootService consumed more random values than the test queued',
);
} }
return values[index++]; return values[index++];
}, },
@@ -40,14 +42,15 @@ function queuedRandom(...values: number[]): RandomSource {
function dataSourceWith(entries: LootTableEntry[]): DataSource { function dataSourceWith(entries: LootTableEntry[]): DataSource {
return { return {
getRepository: jest.fn(() => ({ getRepository: jest.fn(() => ({
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) => find: jest.fn(
entries async (options: { where: { lootTableId: string; enabled: boolean } }) =>
.filter( entries
(candidate) => .filter(
candidate.lootTableId === options.where.lootTableId && (candidate) =>
candidate.enabled === options.where.enabled, candidate.lootTableId === options.where.lootTableId &&
) candidate.enabled === options.where.enabled,
.sort((a, b) => a.position - b.position), )
.sort((a, b) => a.position - b.position),
), ),
})), })),
} as unknown as DataSource; } as unknown as DataSource;
@@ -55,7 +58,12 @@ function dataSourceWith(entries: LootTableEntry[]): DataSource {
describe('LootService', () => { describe('LootService', () => {
const ashRatEntries = [ const ashRatEntries = [
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }), entry({
id: 'entry-pelt',
itemDefinitionId: ASH_PELT,
position: 1,
dropChance: '0.6000',
}),
entry({ entry({
id: 'entry-sword', id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD, itemDefinitionId: WORN_SHORT_SWORD,
@@ -65,7 +73,10 @@ describe('LootService', () => {
]; ];
it('drops an entry when the roll falls under its chance', async () => { it('drops an entry when the roll falls under its chance', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07)); const service = new LootService(
dataSourceWith(ashRatEntries),
queuedRandom(0.59, 0.07),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [ items: [
@@ -76,13 +87,21 @@ describe('LootService', () => {
}); });
it('skips an entry when the roll lands on or above its chance', async () => { it('skips an entry when the roll lands on or above its chance', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08)); const service = new LootService(
dataSourceWith(ashRatEntries),
queuedRandom(0.6, 0.08),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [],
});
}); });
it('rolls each entry independently, so one combat can drop only the second item', async () => { it('rolls each entry independently, so one combat can drop only the second item', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01)); const service = new LootService(
dataSourceWith(ashRatEntries),
queuedRandom(0.9, 0.01),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
@@ -91,10 +110,23 @@ describe('LootService', () => {
it('rolls entries in position order so injected values stay predictable', async () => { it('rolls entries in position order so injected values stay predictable', async () => {
const outOfOrder = [ const outOfOrder = [
entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }), entry({
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }), id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD,
position: 2,
dropChance: '1.0000',
}),
entry({
id: 'entry-pelt',
itemDefinitionId: ASH_PELT,
position: 1,
dropChance: '0.0000',
}),
]; ];
const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5)); const service = new LootService(
dataSourceWith(outOfOrder),
queuedRandom(0.5, 0.5),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
@@ -107,13 +139,20 @@ describe('LootService', () => {
queuedRandom(), queuedRandom(),
); );
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [],
});
}); });
it('consumes no quantity roll when min and max match, and one when they differ', async () => { it('consumes no quantity roll when min and max match, and one when they differ', async () => {
const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })]; const stackable = [
entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 }),
];
// First value drops the entry, second picks the quantity (0.5 -> 3). // First value drops the entry, second picks the quantity (0.5 -> 3).
const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5)); const service = new LootService(
dataSourceWith(stackable),
queuedRandom(0.1, 0.5),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }], items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }],
@@ -121,7 +160,10 @@ describe('LootService', () => {
}); });
it('returns nothing for a monster without a loot table', async () => { it('returns nothing for a monster without a loot table', async () => {
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom()); const service = new LootService(
dataSourceWith(ashRatEntries),
queuedRandom(),
);
await expect(service.rollLoot(null)).resolves.toEqual({ items: [] }); await expect(service.rollLoot(null)).resolves.toEqual({ items: [] });
}); });

View File

@@ -34,9 +34,6 @@ export class MonsterDefinition {
@Column({ name: 'armor', type: 'integer' }) @Column({ name: 'armor', type: 'integer' })
armor!: number; armor!: number;
@Column({ name: 'experience_reward', type: 'integer' })
experienceReward!: number;
@Column({ name: 'silver_min', type: 'integer' }) @Column({ name: 'silver_min', type: 'integer' })
silverMin!: number; silverMin!: number;

View File

@@ -10,9 +10,13 @@ import { Character } from '../../characters/entities/character.entity';
import { RenownMilestoneDefinition } from './renown-milestone-definition.entity'; import { RenownMilestoneDefinition } from './renown-milestone-definition.entity';
@Entity({ name: 'character_renown_milestones' }) @Entity({ name: 'character_renown_milestones' })
@Index('IDX_character_renown_milestones_character_milestone', ['characterId', 'milestoneId'], { @Index(
unique: true, 'IDX_character_renown_milestones_character_milestone',
}) ['characterId', 'milestoneId'],
{
unique: true,
},
)
export class CharacterRenownMilestone { export class CharacterRenownMilestone {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -4,9 +4,11 @@ describe('RENOWN_BASE_STATS', () => {
it('spans exactly Renown 1 through 15', () => { it('spans exactly Renown 1 through 15', () => {
expect(RENOWN_MIN).toBe(1); expect(RENOWN_MIN).toBe(1);
expect(RENOWN_MAX).toBe(15); expect(RENOWN_MAX).toBe(15);
expect(Object.keys(RENOWN_BASE_STATS).map(Number).sort((a, b) => a - b)).toEqual( expect(
Array.from({ length: 15 }, (_, i) => i + 1), Object.keys(RENOWN_BASE_STATS)
); .map(Number)
.sort((a, b) => a - b),
).toEqual(Array.from({ length: 15 }, (_, i) => i + 1));
}); });
it('matches the exact V1 reference table from spec §4', () => { it('matches the exact V1 reference table from spec §4', () => {

View File

@@ -5,7 +5,10 @@ export const RENOWN_MAX = 15;
// total base-stat progression (100 HP / 6 Attack -> 148 HP / 12 Attack) // total base-stat progression (100 HP / 6 Attack -> 148 HP / 12 Attack)
// across Renown 1-15. RenownService looks this up on every milestone // across Renown 1-15. RenownService looks this up on every milestone
// completion; it is never incremented, only reassigned by rank (design R2). // completion; it is never incremented, only reassigned by rank (design R2).
export const RENOWN_BASE_STATS: Record<number, { baseHp: number; baseAttack: number }> = { export const RENOWN_BASE_STATS: Record<
number,
{ baseHp: number; baseAttack: number }
> = {
1: { baseHp: 100, baseAttack: 6 }, 1: { baseHp: 100, baseAttack: 6 },
2: { baseHp: 104, baseAttack: 6 }, 2: { baseHp: 104, baseAttack: 6 },
3: { baseHp: 107, baseAttack: 7 }, 3: { baseHp: 107, baseAttack: 7 },

View File

@@ -7,7 +7,11 @@ import { RenownService } from './renown.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, RenownMilestoneDefinition, CharacterRenownMilestone]), TypeOrmModule.forFeature([
Character,
RenownMilestoneDefinition,
CharacterRenownMilestone,
]),
], ],
providers: [RenownService], providers: [RenownService],
exports: [RenownService], exports: [RenownService],

View File

@@ -21,15 +21,22 @@ class FakeRepository<T extends { id: string }> {
private readonly inTransaction: boolean, private readonly inTransaction: boolean,
) {} ) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> { findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock && !this.inTransaction) { if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction'); throw new Error('Pessimistic locks require a transaction');
} }
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, options.where)) ?? null,
);
} }
findOneBy(where: Partial<T>): Promise<T | null> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, where)) ?? null,
);
} }
create(values: Partial<T>): T { create(values: Partial<T>): T {
@@ -50,7 +57,9 @@ class FakeRepository<T extends { id: string }> {
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
} }
@@ -61,27 +70,54 @@ class FakeDataSource {
return this.repoFor(target, false); return this.repoFor(target, false);
} }
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> { async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({ return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true), getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.repoFor(target, true),
} as unknown as EntityManager); } as unknown as EntityManager);
} }
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) { private repoFor<T extends { id: string }>(
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never; target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character)
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
if (target === RenownMilestoneDefinition) if (target === RenownMilestoneDefinition)
return new FakeRepository(this.state.milestones, 'milestone', inTransaction) as never; return new FakeRepository(
this.state.milestones,
'milestone',
inTransaction,
) as never;
if (target === CharacterRenownMilestone) if (target === CharacterRenownMilestone)
return new FakeRepository(this.state.characterMilestones, 'char-milestone', inTransaction) as never; return new FakeRepository(
this.state.characterMilestones,
'char-milestone',
inTransaction,
) as never;
throw new Error('Unsupported repository'); throw new Error('Unsupported repository');
} }
} }
function character(overrides: Partial<Character> = {}): Character { function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, renown: 1, baseHp: 100, baseAttack: 6, ...overrides } as Character; return {
id: CHARACTER_ID,
renown: 1,
baseHp: 100,
baseAttack: 6,
...overrides,
} as Character;
} }
function milestone(overrides: Partial<RenownMilestoneDefinition> = {}): RenownMilestoneDefinition { function milestone(
overrides: Partial<RenownMilestoneDefinition> = {},
): RenownMilestoneDefinition {
return { return {
id: MILESTONE_ID, id: MILESTONE_ID,
key: 'first-hunt', key: 'first-hunt',
@@ -105,10 +141,16 @@ function createState(overrides: Partial<State> = {}): State {
function createService(state: State) { function createService(state: State) {
const dataSource = new FakeDataSource(state); const dataSource = new FakeDataSource(state);
return { service: new RenownService(dataSource as unknown as DataSource), state }; return {
service: new RenownService(dataSource as unknown as DataSource),
state,
};
} }
async function expectRenownDomainError(promise: Promise<unknown>, code: string): Promise<void> { async function expectRenownDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown; let error: unknown;
try { try {
await promise; await promise;
@@ -127,7 +169,10 @@ describe('RenownService', () => {
it('grants renown and recomputes base stats from the power-curve table', async () => { it('grants renown and recomputes base stats from the power-curve table', async () => {
const { service, state } = createService(createState()); const { service, state } = createService(createState());
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); const result = await service.completeMilestone(
CHARACTER_ID,
'first-hunt',
);
expect(result).toEqual({ expect(result).toEqual({
milestoneKey: 'first-hunt', milestoneKey: 'first-hunt',
@@ -167,11 +212,16 @@ describe('RenownService', () => {
}); });
it('allows a repeatable milestone to grant renown again, incrementing timesCompleted', async () => { it('allows a repeatable milestone to grant renown again, incrementing timesCompleted', async () => {
const state = createState({ milestones: [milestone({ repeatable: true })] }); const state = createState({
milestones: [milestone({ repeatable: true })],
});
const { service } = createService(state); const { service } = createService(state);
await service.completeMilestone(CHARACTER_ID, 'first-hunt'); await service.completeMilestone(CHARACTER_ID, 'first-hunt');
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); const result = await service.completeMilestone(
CHARACTER_ID,
'first-hunt',
);
expect(result.renownGranted).toBe(true); expect(result.renownGranted).toBe(true);
expect(result.newRenown).toBe(3); expect(result.newRenown).toBe(3);
@@ -186,7 +236,10 @@ describe('RenownService', () => {
}); });
const { service } = createService(state); const { service } = createService(state);
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); const result = await service.completeMilestone(
CHARACTER_ID,
'first-hunt',
);
expect(result).toEqual({ expect(result).toEqual({
milestoneKey: 'first-hunt', milestoneKey: 'first-hunt',
@@ -207,7 +260,9 @@ describe('RenownService', () => {
}); });
it('rejects a disabled milestone', async () => { it('rejects a disabled milestone', async () => {
const state = createState({ milestones: [milestone({ enabled: false })] }); const state = createState({
milestones: [milestone({ enabled: false })],
});
const { service } = createService(state); const { service } = createService(state);
await expectRenownDomainError( await expectRenownDomainError(

View File

@@ -33,7 +33,9 @@ export class RenownService {
milestoneKey: string, milestoneKey: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<RenownMilestoneResult> { ): Promise<RenownMilestoneResult> {
const run = async (txManager: EntityManager): Promise<RenownMilestoneResult> => { const run = async (
txManager: EntityManager,
): Promise<RenownMilestoneResult> => {
const characters = txManager.getRepository(Character); const characters = txManager.getRepository(Character);
const milestones = txManager.getRepository(RenownMilestoneDefinition); const milestones = txManager.getRepository(RenownMilestoneDefinition);
const completions = txManager.getRepository(CharacterRenownMilestone); const completions = txManager.getRepository(CharacterRenownMilestone);
@@ -63,7 +65,10 @@ export class RenownService {
} }
const previousRenown = character.renown; const previousRenown = character.renown;
const newRenown = Math.min(RENOWN_MAX, previousRenown + milestone.renownReward); const newRenown = Math.min(
RENOWN_MAX,
previousRenown + milestone.renownReward,
);
const renownGranted = newRenown !== previousRenown; const renownGranted = newRenown !== previousRenown;
if (renownGranted) { if (renownGranted) {

View File

@@ -12,9 +12,13 @@ import { Character } from '../../characters/entities/character.entity';
import { ReputationFaction } from './reputation-faction.entity'; import { ReputationFaction } from './reputation-faction.entity';
@Entity({ name: 'character_reputation' }) @Entity({ name: 'character_reputation' })
@Index('IDX_character_reputation_character_faction', ['characterId', 'factionId'], { @Index(
unique: true, 'IDX_character_reputation_character_faction',
}) ['characterId', 'factionId'],
{
unique: true,
},
)
export class CharacterReputation { export class CharacterReputation {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -7,7 +7,11 @@ export interface ReputationRankInfo {
// Verbatim thresholds from spec §10, German labels for the German-language UI. // Verbatim thresholds from spec §10, German labels for the German-language UI.
// Sorted descending: the first entry whose threshold <= reputation wins. // Sorted descending: the first entry whose threshold <= reputation wins.
const REPUTATION_RANKS: ReadonlyArray<{ threshold: number; key: string; label: string }> = [ const REPUTATION_RANKS: ReadonlyArray<{
threshold: number;
key: string;
label: string;
}> = [
{ threshold: 1200, key: 'ESTEEMED', label: 'Geachtet' }, { threshold: 1200, key: 'ESTEEMED', label: 'Geachtet' },
{ threshold: 800, key: 'TRUSTED', label: 'Vertraut' }, { threshold: 800, key: 'TRUSTED', label: 'Vertraut' },
{ threshold: 500, key: 'RECOGNIZED', label: 'Anerkannt' }, { threshold: 500, key: 'RECOGNIZED', label: 'Anerkannt' },
@@ -17,7 +21,9 @@ const REPUTATION_RANKS: ReadonlyArray<{ threshold: number; key: string; label: s
]; ];
export function resolveReputationRank(reputation: number): ReputationRankInfo { export function resolveReputationRank(reputation: number): ReputationRankInfo {
const index = REPUTATION_RANKS.findIndex((rank) => reputation >= rank.threshold); const index = REPUTATION_RANKS.findIndex(
(rank) => reputation >= rank.threshold,
);
const rank = REPUTATION_RANKS[index]; const rank = REPUTATION_RANKS[index];
const nextRank = index > 0 ? REPUTATION_RANKS[index - 1] : null; const nextRank = index > 0 ? REPUTATION_RANKS[index - 1] : null;

View File

@@ -15,7 +15,9 @@ describe('ReputationController', () => {
getCharacterReputation.mockReset(); getCharacterReputation.mockReset();
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
controllers: [ReputationController], controllers: [ReputationController],
providers: [{ provide: ReputationService, useValue: { getCharacterReputation } }], providers: [
{ provide: ReputationService, useValue: { getCharacterReputation } },
],
}).compile(); }).compile();
app = module.createNestApplication<App>(); app = module.createNestApplication<App>();
@@ -40,7 +42,9 @@ describe('ReputationController', () => {
]; ];
getCharacterReputation.mockResolvedValue(entries); getCharacterReputation.mockResolvedValue(entries);
const response = await request(app.getHttpServer()).get('/api/reputation').expect(200); const response = await request(app.getHttpServer())
.get('/api/reputation')
.expect(200);
expect(getCharacterReputation).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(getCharacterReputation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(entries); expect(response.body).toEqual(entries);

View File

@@ -1,6 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterReputationDto, ReputationService } from './reputation.service'; import {
CharacterReputationDto,
ReputationService,
} from './reputation.service';
@Controller('reputation') @Controller('reputation')
export class ReputationController { export class ReputationController {

View File

@@ -22,20 +22,29 @@ class FakeRepository<T extends { id: string }> {
private readonly inTransaction: boolean, private readonly inTransaction: boolean,
) {} ) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> { findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock && !this.inTransaction) { if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction'); throw new Error('Pessimistic locks require a transaction');
} }
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, options.where)) ?? null,
);
} }
findOneBy(where: Partial<T>): Promise<T | null> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, where)) ?? null,
);
} }
find(options: { where?: Partial<T> } = {}): Promise<T[]> { find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve( return Promise.resolve(
options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows], options.where
? this.rows.filter((row) => this.matches(row, options.where!))
: [...this.rows],
); );
} }
@@ -57,7 +66,9 @@ class FakeRepository<T extends { id: string }> {
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
} }
@@ -68,19 +79,37 @@ class FakeDataSource {
return this.repoFor(target, false); return this.repoFor(target, false);
} }
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> { async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({ return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true), getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.repoFor(target, true),
} as unknown as EntityManager); } as unknown as EntityManager);
} }
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) { private repoFor<T extends { id: string }>(
target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character) if (target === Character)
return new FakeRepository(this.state.characters, 'character', inTransaction) as never; return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
if (target === ReputationFaction) if (target === ReputationFaction)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never; return new FakeRepository(
this.state.factions,
'faction',
inTransaction,
) as never;
if (target === CharacterReputation) if (target === CharacterReputation)
return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never; return new FakeRepository(
this.state.characterReputation,
'char-rep',
inTransaction,
) as never;
throw new Error('Unsupported repository'); throw new Error('Unsupported repository');
} }
} }
@@ -89,7 +118,9 @@ function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, ...overrides } as Character; return { id: CHARACTER_ID, ...overrides } as Character;
} }
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction { function faction(
overrides: Partial<ReputationFaction> = {},
): ReputationFaction {
return { return {
id: FACTION_ID, id: FACTION_ID,
key: 'border-guard', key: 'border-guard',
@@ -102,15 +133,26 @@ function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction
} }
function createState(overrides: Partial<State> = {}): State { function createState(overrides: Partial<State> = {}): State {
return { characters: [character()], factions: [faction()], characterReputation: [], ...overrides }; return {
characters: [character()],
factions: [faction()],
characterReputation: [],
...overrides,
};
} }
function createService(state: State) { function createService(state: State) {
const dataSource = new FakeDataSource(state); const dataSource = new FakeDataSource(state);
return { service: new ReputationService(dataSource as unknown as DataSource), state }; return {
service: new ReputationService(dataSource as unknown as DataSource),
state,
};
} }
async function expectReputationDomainError(promise: Promise<unknown>, code: string): Promise<void> { async function expectReputationDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown; let error: unknown;
try { try {
await promise; await promise;
@@ -154,7 +196,11 @@ describe('ReputationService', () => {
it('creates a reputation row starting from 0 on the first grant', async () => { it('creates a reputation row starting from 0 on the first grant', async () => {
const { service, state } = createService(createState()); const { service, state } = createService(createState());
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 40); const result = await service.grantReputation(
CHARACTER_ID,
'border-guard',
40,
);
expect(result).toEqual({ expect(result).toEqual({
factionKey: 'border-guard', factionKey: 'border-guard',
@@ -171,12 +217,21 @@ describe('ReputationService', () => {
it('accumulates onto an existing reputation row', async () => { it('accumulates onto an existing reputation row', async () => {
const state = createState({ const state = createState({
characterReputation: [ characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation, {
id: 'existing',
characterId: CHARACTER_ID,
factionId: FACTION_ID,
reputation: 90,
} as CharacterReputation,
], ],
}); });
const { service } = createService(state); const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20); const result = await service.grantReputation(
CHARACTER_ID,
'border-guard',
20,
);
expect(result.previousReputation).toBe(90); expect(result.previousReputation).toBe(90);
expect(result.newReputation).toBe(110); expect(result.newReputation).toBe(110);
@@ -185,12 +240,21 @@ describe('ReputationService', () => {
it('reports rankChanged when a grant crosses a threshold', async () => { it('reports rankChanged when a grant crosses a threshold', async () => {
const state = createState({ const state = createState({
characterReputation: [ characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation, {
id: 'existing',
characterId: CHARACTER_ID,
factionId: FACTION_ID,
reputation: 90,
} as CharacterReputation,
], ],
}); });
const { service } = createService(state); const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20); const result = await service.grantReputation(
CHARACTER_ID,
'border-guard',
20,
);
expect(result.previousRank).toBe('STRANGER'); expect(result.previousRank).toBe('STRANGER');
expect(result.newRank).toBe('TOLERATED'); expect(result.newRank).toBe('TOLERATED');
@@ -200,12 +264,21 @@ describe('ReputationService', () => {
it('reports rankChanged: false when a grant stays within the same rank', async () => { it('reports rankChanged: false when a grant stays within the same rank', async () => {
const state = createState({ const state = createState({
characterReputation: [ characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 120 } as CharacterReputation, {
id: 'existing',
characterId: CHARACTER_ID,
factionId: FACTION_ID,
reputation: 120,
} as CharacterReputation,
], ],
}); });
const { service } = createService(state); const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 10); const result = await service.grantReputation(
CHARACTER_ID,
'border-guard',
10,
);
expect(result.rankChanged).toBe(false); expect(result.rankChanged).toBe(false);
}); });

View File

@@ -3,7 +3,10 @@ import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { CharacterReputation } from './entities/character-reputation.entity'; import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity'; import { ReputationFaction } from './entities/reputation-faction.entity';
import { characterNotFound, reputationFactionNotFound } from './reputation.errors'; import {
characterNotFound,
reputationFactionNotFound,
} from './reputation.errors';
import { resolveReputationRank } from './reputation-rank'; import { resolveReputationRank } from './reputation-rank';
export interface ReputationGrantResult { export interface ReputationGrantResult {
@@ -37,7 +40,9 @@ export class ReputationService {
amount: number, amount: number,
manager?: EntityManager, manager?: EntityManager,
): Promise<ReputationGrantResult> { ): Promise<ReputationGrantResult> {
const run = async (txManager: EntityManager): Promise<ReputationGrantResult> => { const run = async (
txManager: EntityManager,
): Promise<ReputationGrantResult> => {
const characters = txManager.getRepository(Character); const characters = txManager.getRepository(Character);
const factions = txManager.getRepository(ReputationFaction); const factions = txManager.getRepository(ReputationFaction);
const reputations = txManager.getRepository(CharacterReputation); const reputations = txManager.getRepository(CharacterReputation);
@@ -74,7 +79,11 @@ export class ReputationService {
await reputations.save(existing); await reputations.save(existing);
} else { } else {
await reputations.save( await reputations.save(
reputations.create({ characterId, factionId: faction.id, reputation: newReputation }), reputations.create({
characterId,
factionId: faction.id,
reputation: newReputation,
}),
); );
} }
@@ -99,10 +108,16 @@ export class ReputationService {
* never interacted with reads as 0 Reputation / Stranger (spec §36, * never interacted with reads as 0 Reputation / Stranger (spec §36,
* design R10), not as an absent entry. * design R10), not as an absent entry.
*/ */
async getCharacterReputation(characterId: string): Promise<CharacterReputationDto[]> { async getCharacterReputation(
characterId: string,
): Promise<CharacterReputationDto[]> {
const scope: RepositoryScope = this.dataSource; const scope: RepositoryScope = this.dataSource;
const factions = await scope.getRepository(ReputationFaction).find({ where: { enabled: true } }); const factions = await scope
const reputations = await scope.getRepository(CharacterReputation).find({ where: { characterId } }); .getRepository(ReputationFaction)
.find({ where: { enabled: true } });
const reputations = await scope
.getRepository(CharacterReputation)
.find({ where: { characterId } });
return factions.map((faction) => { return factions.map((faction) => {
const existing = reputations.find((row) => row.factionId === faction.id); const existing = reputations.find((row) => row.factionId === faction.id);

View File

@@ -39,11 +39,15 @@ class FakeRepository<T extends { id: string }> {
) {} ) {}
findOne(options: { where: Partial<T> }): Promise<T | null> { findOne(options: { where: Partial<T> }): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, options.where)) ?? null,
);
} }
findOneBy(where: Partial<T>): Promise<T | null> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, where)) ?? null,
);
} }
find(options: { find(options: {
@@ -57,7 +61,10 @@ class FakeRepository<T extends { id: string }> {
// Mirrors TypeORM's `order` clause so tests can prove ordering comes from // Mirrors TypeORM's `order` clause so tests can prove ordering comes from
// the query, not from insertion order happening to line up. // the query, not from insertion order happening to line up.
const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC']; const [key, direction] = Object.entries(options.order)[0] as [
keyof T,
'ASC' | 'DESC',
];
const sorted = [...matched].sort((a, b) => { const sorted = [...matched].sort((a, b) => {
const left = a[key]; const left = a[key];
const right = b[key]; const right = b[key];
@@ -85,23 +92,33 @@ class FakeRepository<T extends { id: string }> {
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
} }
function fakeManager(state: State): EntityManager { function fakeManager(state: State): EntityManager {
return { return {
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => { getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
if (target === Character) return new FakeRepository(state.characters, 'character') as never; if (target === Character)
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never; return new FakeRepository(state.characters, 'character') as never;
if (target === MonsterDefinition)
return new FakeRepository(state.monsters, 'monster') as never;
if (target === ItemDefinition) if (target === ItemDefinition)
return new FakeRepository(state.itemDefinitions, 'definition') as never; return new FakeRepository(state.itemDefinitions, 'definition') as never;
if (target === CharacterItem) if (target === CharacterItem)
return new FakeRepository(state.characterItems, 'character-item') as never; return new FakeRepository(
state.characterItems,
'character-item',
) as never;
if (target === CombatReward) if (target === CombatReward)
return new FakeRepository(state.combatRewards, 'reward') as never; return new FakeRepository(state.combatRewards, 'reward') as never;
if (target === CombatRewardItem) if (target === CombatRewardItem)
return new FakeRepository(state.combatRewardItems, 'reward-item') as never; return new FakeRepository(
state.combatRewardItems,
'reward-item',
) as never;
throw new Error('Unsupported repository'); throw new Error('Unsupported repository');
}, },
} as unknown as EntityManager; } as unknown as EntityManager;
@@ -162,8 +179,12 @@ function fakeDataSource(state: State): EntityManager {
return fakeManager(state); return fakeManager(state);
} }
function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService { function fakeLoot(
return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService; ...items: Array<{ itemDefinitionId: string; quantity: number }>
): LootService {
return {
rollLoot: jest.fn().mockResolvedValue({ items }),
} as unknown as LootService;
} }
function fixedRandom(value: number): RandomSource { function fixedRandom(value: number): RandomSource {
@@ -184,7 +205,10 @@ describe('CombatRewardService', () => {
const state = createState(); const state = createState();
await expect( await expect(
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })), service(state).grantVictoryRewards(
fakeManager(state),
combat({ status: CombatStatus.ACTIVE }),
),
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
expect(state.combatRewards).toHaveLength(0); expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].silver).toBe(3); expect(state.characters[0].silver).toBe(3);
@@ -194,7 +218,10 @@ describe('CombatRewardService', () => {
const state = createState(); const state = createState();
await expect( await expect(
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })), service(state).grantVictoryRewards(
fakeManager(state),
combat({ status: CombatStatus.LOST }),
),
).rejects.toBeInstanceOf(RewardDomainError); ).rejects.toBeInstanceOf(RewardDomainError);
expect(state.combatRewards).toHaveLength(0); expect(state.combatRewards).toHaveLength(0);
}); });
@@ -202,7 +229,10 @@ describe('CombatRewardService', () => {
it('grants rewards for a WON combat', async () => { it('grants rewards for a WON combat', async () => {
const state = createState(); const state = createState();
const reward = await service(state).grantVictoryRewards(fakeManager(state), combat()); const reward = await service(state).grantVictoryRewards(
fakeManager(state),
combat(),
);
expect(reward).toEqual({ silver: 6, items: [] }); expect(reward).toEqual({ silver: 6, items: [] });
expect(state.combatRewards).toHaveLength(1); expect(state.combatRewards).toHaveLength(1);
@@ -213,10 +243,11 @@ describe('CombatRewardService', () => {
it('grants a silver roll inside 4-7, persisted on the character', async () => { it('grants a silver roll inside 4-7, persisted on the character', async () => {
const state = createState(); const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( const reward = await service(
fakeManager(state), state,
combat(), fakeLoot(),
); fixedRandom(0),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(4); expect(reward.silver).toBe(4);
expect(state.characters[0].silver).toBe(7); expect(state.characters[0].silver).toBe(7);
@@ -225,10 +256,11 @@ describe('CombatRewardService', () => {
it('rolls the top of the silver range from the top of the random range', async () => { it('rolls the top of the silver range from the top of the random range', async () => {
const state = createState(); const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards( const reward = await service(
fakeManager(state), state,
combat(), fakeLoot(),
); fixedRandom(0.99),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(7); expect(reward.silver).toBe(7);
}); });
@@ -240,10 +272,11 @@ describe('CombatRewardService', () => {
it('grants a silver roll inside 9-15', async () => { it('grants a silver roll inside 9-15', async () => {
const state = createState(); const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( const reward = await service(
fakeManager(state), state,
banditCombat, fakeLoot(),
); fixedRandom(0),
).grantVictoryRewards(fakeManager(state), banditCombat);
expect(reward.silver).toBe(9); expect(reward.silver).toBe(9);
}); });
@@ -361,7 +394,11 @@ describe('CombatRewardService', () => {
const state = createState(); const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const dataSource = fakeDataSource(state); const dataSource = fakeDataSource(state);
const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0)); const subject = new CombatRewardService(
dataSource as never,
loot,
fixedRandom(0),
);
const manager = fakeManager(state); const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, combat()); const granted = await subject.grantVictoryRewards(manager, combat());
@@ -379,10 +416,10 @@ describe('CombatRewardService', () => {
const state = createState(); const state = createState();
await expect( await expect(
service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards( service(
fakeManager(state), state,
combat(), fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 }),
), ).grantVictoryRewards(fakeManager(state), combat()),
).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' }); ).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' });
expect(state.combatRewardItems).toHaveLength(0); expect(state.combatRewardItems).toHaveLength(0);
// Every rolled item definition is resolved before any mutation, so a // Every rolled item definition is resolved before any mutation, so a
@@ -430,7 +467,9 @@ describe('CombatRewardService', () => {
const expectedKeys = ['bandit-hood', 'bandit-blade']; const expectedKeys = ['bandit-hood', 'bandit-blade'];
expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys); expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys);
expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys); expect(replayed?.items.map((item) => item.item.key)).toEqual(
expectedKeys,
);
expect(replayed).toEqual(granted); expect(replayed).toEqual(granted);
}); });
}); });

View File

@@ -122,7 +122,10 @@ export class CombatRewardService {
const characterItems = manager.getRepository(CharacterItem); const characterItems = manager.getRepository(CharacterItem);
const rewardItems = manager.getRepository(CombatRewardItem); const rewardItems = manager.getRepository(CombatRewardItem);
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = []; const granted: Array<{
itemDefinitionId: string;
dto: CombatRewardItemDto;
}> = [];
for (const rolled of roll.items) { for (const rolled of roll.items) {
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!; const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
@@ -164,7 +167,9 @@ export class CombatRewardService {
// The immediate response and a later `loadRewards` replay must agree on // The immediate response and a later `loadRewards` replay must agree on
// item order; both sort on the same stable key (itemDefinitionId, which // item order; both sort on the same stable key (itemDefinitionId, which
// `toDto`'s query also orders by) rather than roll order. // `toDto`'s query also orders by) rather than roll order.
granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId)); granted.sort((a, b) =>
a.itemDefinitionId.localeCompare(b.itemDefinitionId),
);
const items = granted.map((entry) => entry.dto); const items = granted.map((entry) => entry.dto);
return { silver, items }; return { silver, items };
@@ -190,12 +195,10 @@ export class CombatRewardService {
// Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards` // Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards`
// applies to its own response — the immediate grant and a later replay // applies to its own response — the immediate grant and a later replay
// must list items identically. // must list items identically.
const rewardItems = await scope const rewardItems = await scope.getRepository(CombatRewardItem).find({
.getRepository(CombatRewardItem) where: { combatRewardId: reward.id },
.find({ order: { itemDefinitionId: 'ASC' },
where: { combatRewardId: reward.id }, });
order: { itemDefinitionId: 'ASC' },
});
const definitions = scope.getRepository(ItemDefinition); const definitions = scope.getRepository(ItemDefinition);
const items: CombatRewardItemDto[] = []; const items: CombatRewardItemDto[] = [];
@@ -208,7 +211,11 @@ export class CombatRewardService {
throw rewardStateInvalid(); throw rewardStateInvalid();
} }
items.push( items.push(
this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity), this.toItemDto(
rewardItem.characterItemId,
definition,
rewardItem.quantity,
),
); );
} }

View File

@@ -20,9 +20,13 @@ import { CombatReward } from './combat-reward.entity';
*/ */
@Entity({ name: 'combat_reward_items' }) @Entity({ name: 'combat_reward_items' })
@Index('IDX_combat_reward_items_reward', ['combatRewardId']) @Index('IDX_combat_reward_items_reward', ['combatRewardId'])
@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], { @Index(
unique: true, 'IDX_combat_reward_items_reward_item',
}) ['combatRewardId', 'itemDefinitionId'],
{
unique: true,
},
)
export class CombatRewardItem { export class CombatRewardItem {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string; id!: string;

View File

@@ -48,14 +48,23 @@ describe('TurnInController', () => {
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 }) .send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 })
.expect(201); .expect(201);
expect(turnIn).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'ash-pelt-border-guard', 3); expect(turnIn).toHaveBeenCalledWith(
DEMO_CHARACTER_ID,
'ash-pelt-border-guard',
3,
);
expect(response.body).toEqual(result); expect(response.body).toEqual(result);
}); });
it('rejects server-owned reward fields the client must never send', async () => { it('rejects server-owned reward fields the client must never send', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/turn-ins') .post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3, silverGranted: 999, reputationReward: 999 }) .send({
turnInKey: 'ash-pelt-border-guard',
quantity: 3,
silverGranted: 999,
reputationReward: 999,
})
.expect(400); .expect(400);
expect(turnIn).not.toHaveBeenCalled(); expect(turnIn).not.toHaveBeenCalled();

View File

@@ -9,6 +9,10 @@ export class TurnInController {
@Post() @Post()
turnIn(@Body() request: TurnInDto): Promise<TurnInResult> { turnIn(@Body() request: TurnInDto): Promise<TurnInResult> {
return this.turnInService.turnIn(DEMO_CHARACTER_ID, request.turnInKey, request.quantity); return this.turnInService.turnIn(
DEMO_CHARACTER_ID,
request.turnInKey,
request.quantity,
);
} }
} }

View File

@@ -10,7 +10,12 @@ import { TurnInService } from './turn-in.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, CharacterItem, TurnInDefinition, ReputationFaction]), TypeOrmModule.forFeature([
Character,
CharacterItem,
TurnInDefinition,
ReputationFaction,
]),
ReputationModule, ReputationModule,
], ],
controllers: [TurnInController], controllers: [TurnInController],

View File

@@ -29,20 +29,29 @@ class FakeRepository<T extends { id: string }> {
private readonly inTransaction: boolean, private readonly inTransaction: boolean,
) {} ) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> { findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock && !this.inTransaction) { if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction'); throw new Error('Pessimistic locks require a transaction');
} }
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, options.where)) ?? null,
);
} }
findOneBy(where: Partial<T>): Promise<T | null> { findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); return Promise.resolve(
this.rows.find((row) => this.matches(row, where)) ?? null,
);
} }
find(options: { where?: Partial<T> } = {}): Promise<T[]> { find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve( return Promise.resolve(
options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows], options.where
? this.rows.filter((row) => this.matches(row, options.where!))
: [...this.rows],
); );
} }
@@ -72,7 +81,9 @@ class FakeRepository<T extends { id: string }> {
} }
private matches(row: T, where: Partial<T>): boolean { private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
} }
} }
@@ -83,22 +94,49 @@ class FakeDataSource {
return this.repoFor(target, false); return this.repoFor(target, false);
} }
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> { async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({ return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true), getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.repoFor(target, true),
} as unknown as EntityManager); } as unknown as EntityManager);
} }
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) { private repoFor<T extends { id: string }>(
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never; target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character)
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
if (target === CharacterItem) if (target === CharacterItem)
return new FakeRepository(this.state.characterItems, 'char-item', inTransaction) as never; return new FakeRepository(
this.state.characterItems,
'char-item',
inTransaction,
) as never;
if (target === TurnInDefinition) if (target === TurnInDefinition)
return new FakeRepository(this.state.turnIns, 'turn-in', inTransaction) as never; return new FakeRepository(
this.state.turnIns,
'turn-in',
inTransaction,
) as never;
if (target === ReputationFaction) if (target === ReputationFaction)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never; return new FakeRepository(
this.state.factions,
'faction',
inTransaction,
) as never;
if (target === CharacterReputation) if (target === CharacterReputation)
return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never; return new FakeRepository(
this.state.characterReputation,
'char-rep',
inTransaction,
) as never;
throw new Error('Unsupported repository'); throw new Error('Unsupported repository');
} }
} }
@@ -117,7 +155,9 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
} as CharacterItem; } as CharacterItem;
} }
function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefinition { function turnInDefinition(
overrides: Partial<TurnInDefinition> = {},
): TurnInDefinition {
return { return {
id: 'turn-in-1', id: 'turn-in-1',
key: 'ash-pelt-border-guard', key: 'ash-pelt-border-guard',
@@ -131,8 +171,16 @@ function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefi
} as TurnInDefinition; } as TurnInDefinition;
} }
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction { function faction(
return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', enabled: true, ...overrides } as ReputationFaction; overrides: Partial<ReputationFaction> = {},
): ReputationFaction {
return {
id: FACTION_ID,
key: 'border-guard',
name: 'Grenzwacht',
enabled: true,
...overrides,
} as ReputationFaction;
} }
function createState(overrides: Partial<State> = {}): State { function createState(overrides: Partial<State> = {}): State {
@@ -148,14 +196,22 @@ function createState(overrides: Partial<State> = {}): State {
function createService(state: State) { function createService(state: State) {
const dataSource = new FakeDataSource(state); const dataSource = new FakeDataSource(state);
const reputationService = new ReputationService(dataSource as unknown as DataSource); const reputationService = new ReputationService(
dataSource as unknown as DataSource,
);
return { return {
service: new TurnInService(dataSource as unknown as DataSource, reputationService), service: new TurnInService(
dataSource as unknown as DataSource,
reputationService,
),
state, state,
}; };
} }
async function expectTurnInDomainError(promise: Promise<unknown>, code: string): Promise<void> { async function expectTurnInDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown; let error: unknown;
try { try {
await promise; await promise;
@@ -198,7 +254,11 @@ describe('TurnInService', () => {
it('consumes the exact quantity, grants silver and reputation atomically', async () => { it('consumes the exact quantity, grants silver and reputation atomically', async () => {
const { service, state } = createService(createState()); const { service, state } = createService(createState());
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3); const result = await service.turnIn(
CHARACTER_ID,
'ash-pelt-border-guard',
3,
);
expect(result).toEqual({ expect(result).toEqual({
turnInKey: 'ash-pelt-border-guard', turnInKey: 'ash-pelt-border-guard',
@@ -219,7 +279,9 @@ describe('TurnInService', () => {
}); });
it('deletes the CharacterItem row once its quantity reaches 0', async () => { it('deletes the CharacterItem row once its quantity reaches 0', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 3 })] })); const { service, state } = createService(
createState({ characterItems: [characterItem({ quantity: 3 })] }),
);
await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3); await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
@@ -227,7 +289,9 @@ describe('TurnInService', () => {
}); });
it('rejects turning in more than the character owns, mutating nothing', async () => { it('rejects turning in more than the character owns, mutating nothing', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 2 })] })); const { service, state } = createService(
createState({ characterItems: [characterItem({ quantity: 2 })] }),
);
await expectTurnInDomainError( await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3), service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3),
@@ -271,7 +335,9 @@ describe('TurnInService', () => {
}); });
it('rejects a disabled turn-in', async () => { it('rejects a disabled turn-in', async () => {
const state = createState({ turnIns: [turnInDefinition({ enabled: false })] }); const state = createState({
turnIns: [turnInDefinition({ enabled: false })],
});
const { service } = createService(state); const { service } = createService(state);
await expectTurnInDomainError( await expectTurnInDomainError(
@@ -282,11 +348,20 @@ describe('TurnInService', () => {
it('computes multi-item rewards server-side from the definition, not from client input', async () => { it('computes multi-item rewards server-side from the definition, not from client input', async () => {
const state = createState({ const state = createState({
turnIns: [turnInDefinition({ silverRewardPerItem: 12, reputationRewardPerItem: 4 })], turnIns: [
turnInDefinition({
silverRewardPerItem: 12,
reputationRewardPerItem: 4,
}),
],
}); });
const { service, state: resultState } = createService(state); const { service, state: resultState } = createService(state);
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 5); const result = await service.turnIn(
CHARACTER_ID,
'ash-pelt-border-guard',
5,
);
expect(result.silverGranted).toBe(60); expect(result.silverGranted).toBe(60);
expect(result.reputationResult.newReputation).toBe(20); expect(result.reputationResult.newReputation).toBe(20);

View File

@@ -3,7 +3,10 @@ import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity'; import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationGrantResult, ReputationService } from '../reputation/reputation.service'; import {
ReputationGrantResult,
ReputationService,
} from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity'; import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { import {
characterNotFound, characterNotFound,
@@ -33,7 +36,11 @@ export class TurnInService {
* untouched. The reward math is entirely server-computed from persisted * untouched. The reward math is entirely server-computed from persisted
* content -- the client supplies only `turnInKey`/`quantity` (spec §25). * content -- the client supplies only `turnInKey`/`quantity` (spec §25).
*/ */
async turnIn(characterId: string, turnInKey: string, quantity: number): Promise<TurnInResult> { async turnIn(
characterId: string,
turnInKey: string,
quantity: number,
): Promise<TurnInResult> {
if (!Number.isInteger(quantity) || quantity <= 0) { if (!Number.isInteger(quantity) || quantity <= 0) {
throw turnInInvalidQuantity(); throw turnInInvalidQuantity();
} }
@@ -90,7 +97,12 @@ export class TurnInService {
manager, manager,
); );
return { turnInKey, quantityConsumed: quantity, silverGranted, reputationResult }; return {
turnInKey,
quantityConsumed: quantity,
silverGranted,
reputationResult,
};
}); });
} }
} }

View File

@@ -115,9 +115,7 @@ describe('WorldService.runLocalInteraction', () => {
}); });
it('settles travel before resolving which location the character stands at', async () => { it('settles travel before resolving which location the character stands at', async () => {
const completeTravelIfDue = jest const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' });
.fn()
.mockResolvedValue({ status: 'IDLE' });
const service = createService( const service = createService(
location(BURNED_ROAD_ID, BURNED_ROAD_POIS), location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
completeTravelIfDue, completeTravelIfDue,
@@ -155,6 +153,8 @@ describe('WorldService.runLocalInteraction', () => {
it('rejects a navigation hotspot that has no result to reveal', async () => { it('rejects a navigation hotspot that has no result to reveal', async () => {
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area')); await expectRejected(
service.runLocalInteraction(CHARACTER_ID, 'hunt-area'),
);
}); });
}); });

View File

@@ -4,7 +4,9 @@ import { WorldService } from './world.service';
describe('WorldController', () => { describe('WorldController', () => {
it('resolves the current location for the acting character', () => { it('resolves the current location for the acting character', () => {
const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' }); const getCurrentLocation = jest
.fn()
.mockResolvedValue({ key: 'burned-road' });
const controller = new WorldController({ const controller = new WorldController({
getCurrentLocation, getCurrentLocation,
} as unknown as WorldService); } as unknown as WorldService);

View File

@@ -2,10 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { import { calculateDangerRating, DangerRating } from '../hunting/danger-rating';
calculateDangerRating,
DangerRating,
} from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
@@ -107,9 +104,8 @@ export class WorldService {
localArtworkPath: location.localArtworkPath, localArtworkPath: location.localArtworkPath,
dangerRating: this.toLocalDangerRating(character, pool), dangerRating: this.toLocalDangerRating(character, pool),
recommendationLabel: this.toRecommendationLabel(location), recommendationLabel: this.toRecommendationLabel(location),
pointsOfInterest: location.localPointsOfInterest.map( pointsOfInterest:
toPointOfInterestDto, location.localPointsOfInterest.map(toPointOfInterestDto),
),
primaryActions: location.localPrimaryActions, primaryActions: location.localPrimaryActions,
encounterPreview: pool.map((entry) => ({ encounterPreview: pool.map((entry) => ({
key: entry.monster.key, key: entry.monster.key,