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>;
};
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
function fakeScope(
equipped: EquippedFixture[],
): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({
slot: entry.slot,
characterItem: {
@@ -25,7 +27,7 @@ function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository
}));
return {
getRepository: () => ({ find: async () => rows }) as never,
} as unknown as Pick<DataSource, 'getRepository'>;
};
}
function character(overrides: Partial<Character> = {}): Character {
@@ -42,7 +44,9 @@ describe('CharacterStatsService', () => {
const service = new CharacterStatsService({} as DataSource);
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);
@@ -52,9 +56,12 @@ describe('CharacterStatsService', () => {
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([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
{
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
]);
const stats = await service.calculate(character(), scope);
@@ -86,7 +93,9 @@ describe('CharacterStatsService', () => {
});
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);
@@ -95,7 +104,10 @@ describe('CharacterStatsService', () => {
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
{
slot: EquipmentSlot.WEAPON,
item: { weaponDamage: 11, bonusAttack: 1 },
},
{ 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', () => {
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 {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
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));
}

View File

@@ -1,11 +1,16 @@
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 { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
function baseState(
overrides: Partial<CombatEngineState> = {},
): CombatEngineState {
return {
status: CombatStatus.ACTIVE,
round: 1,
@@ -31,7 +36,9 @@ describe('CombatEngineService', () => {
});
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
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', () => {
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
expect(result.state.player.currentHp).toBe(100 - 5);
@@ -69,14 +78,27 @@ describe('CombatEngineService', () => {
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.round).toBe(1);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
},
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
},
]);
});
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
const state = baseState({
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
player: {
currentHp: 3,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
@@ -84,9 +106,23 @@ describe('CombatEngineService', () => {
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
},
]);
});
@@ -106,7 +142,9 @@ describe('CombatEngineService', () => {
});
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
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', () => {
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
expect(result.events[0]).toEqual({
@@ -128,7 +168,9 @@ describe('CombatEngineService', () => {
type: CombatEventType.DAMAGE,
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', () => {
@@ -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([
{ 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.player.currentHp).toBe(100);
@@ -152,13 +205,24 @@ describe('CombatEngineService', () => {
});
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);
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
{
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);
});
@@ -215,10 +279,14 @@ describe('CombatEngineService', () => {
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 });
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
const telegraphResult = engine.resolveAction(round3State, {
action: CombatAction.ATTACK,
});
expect(telegraphResult.state.player.currentHp).toBe(100);
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(telegraphResult.state.monster.stats.pendingAction).toBe(
'HEAVY_ATTACK',
);
expect(telegraphResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
@@ -226,7 +294,9 @@ describe('CombatEngineService', () => {
});
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
expect(resolveResult.events[1]).toEqual({
@@ -254,16 +324,29 @@ describe('CombatEngineService', () => {
expect(result.state.player.currentHp).toBe(100);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
},
{
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
},
]);
});
it('is deterministic for HEAVY_STRIKE as well', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
const first = engine.resolveAction(state, {
action: CombatAction.HEAVY_STRIKE,
});
const second = engine.resolveAction(state, {
action: CombatAction.HEAVY_STRIKE,
});
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
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.player.currentHp).toBe(100 - 4);

View File

@@ -30,7 +30,10 @@ const POTION_HEAL_FRACTION = 0.35;
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
resolveAction(
state: CombatEngineState,
input: CombatActionInput,
): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
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 monster = this.cloneCombatant(state.monster);
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);
events.push({
source: Combatant.PLAYER,
@@ -69,7 +79,11 @@ export class CombatEngineService {
const monster = this.cloneCombatant(state.monster);
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);
events.push({
source: Combatant.PLAYER,
@@ -82,7 +96,11 @@ export class CombatEngineService {
if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined;
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);
@@ -92,7 +110,11 @@ export class CombatEngineService {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
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);
@@ -108,7 +130,12 @@ export class CombatEngineService {
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
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);
@@ -123,22 +150,50 @@ export class CombatEngineService {
interrupted = false,
): CombatEngineResult {
if (monster.currentHp <= 0) {
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } };
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events };
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
const defeatedMonster = {
...monster,
stats: { ...monster.stats, pendingAction: undefined },
};
return {
state: {
...state,
player,
monster: defeatedMonster,
status: CombatStatus.WON,
},
events,
};
}
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
if (player.currentHp <= 0) {
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
}
}
return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
state: {
...state,
player,
monster,
status: CombatStatus.ACTIVE,
round: state.round + 1,
},
events,
};
}
@@ -154,7 +209,11 @@ export class CombatEngineService {
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
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);
events.push({
source: Combatant.MONSTER,
@@ -167,11 +226,19 @@ export class CombatEngineService {
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
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;
}
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);
events.push({
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 } };
}
}

View File

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

View File

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

View File

@@ -19,7 +19,14 @@ export class CombatController {
}
@Post(':combatId/actions')
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
performAction(
@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({
imports: [
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
TypeOrmModule.forFeature([
Character,
Hunt,
HuntEncounter,
MonsterDefinition,
Combat,
CombatEvent,
]),
TravelModule,
CharactersModule,
RewardsModule,

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], {
unique: true,
})
export class CombatEvent {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
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 () => {
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);
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"',
);
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 INDEX "IDX_loot_tables_key"');
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
* existing rows valid; the seed replaces them with authored content.
*/
export class CreateLocalLocationView1788700000000
implements MigrationInterface
{
export class CreateLocalLocationView1788700000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',

View File

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

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)',
);
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) ---
await queryRunner.query(
@@ -48,6 +50,14 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
);
// --- MonsterDefinition: XP is abolished as a concept (spec §1, design R7).
// `silver_min`/`silver_max` deliberately stay -- spec §15 keeps a direct
// currency drop available as a lore-valid exception -- but XP has no such
// carve-out, so the column goes with the concept. ---
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"',
);
// --- ReputationFaction (spec §9) ---
await queryRunner.query(`CREATE TABLE "reputation_factions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
@@ -141,14 +151,22 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'DROP INDEX "IDX_character_renown_milestones_character_milestone"',
);
await queryRunner.query('DROP TABLE "character_renown_milestones"');
await queryRunner.query('DROP INDEX "IDX_renown_milestone_definitions_key"');
await queryRunner.query(
'DROP INDEX "IDX_renown_milestone_definitions_key"',
);
await queryRunner.query('DROP TABLE "renown_milestone_definitions"');
await queryRunner.query('DROP INDEX "IDX_character_reputation_character_faction"');
await queryRunner.query(
'DROP INDEX "IDX_character_reputation_character_faction"',
);
await queryRunner.query('DROP TABLE "character_reputation"');
await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"');
await queryRunner.query('DROP TABLE "reputation_factions"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0',
);
@@ -175,12 +193,16 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac
'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(
'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0',
);
await queryRunner.query('UPDATE "characters" SET "level" = "renown"');
await queryRunner.query('ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"');
await queryRunner.query(
'ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"',
);
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"');
}
}

View File

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

View File

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

View File

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

View File

@@ -29,21 +29,31 @@ describe('loot and rewards schema', () => {
});
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', () => {
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true);
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(
true,
);
expect(
uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId']),
).toBe(true);
});
it('maps reward and loot relations with the documented onDelete behavior', () => {
const relations = getMetadataArgsStorage().relations.filter((relation) =>
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes(
relation.target as never,
),
[
CombatReward,
CombatRewardItem,
CharacterItem,
LootTableEntry,
MonsterDefinition,
].includes(relation.target as never),
);
expect(
@@ -54,16 +64,56 @@ describe('loot and rewards schema', () => {
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'combat',
target: CombatReward,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'character',
target: CombatReward,
}),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'combatReward',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'characterItem',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: CombatRewardItem,
}),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'character',
target: CharacterItem,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: CharacterItem,
}),
expect.objectContaining({
onDelete: 'CASCADE',
propertyName: 'lootTable',
target: LootTableEntry,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'itemDefinition',
target: LootTableEntry,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'lootTable',
target: MonsterDefinition,
}),
]),
);
});
@@ -72,14 +122,16 @@ describe('loot and rewards schema', () => {
const columns = getMetadataArgsStorage().columns;
const silver = columns.find(
(candidate) => candidate.target === Character && candidate.propertyName === 'silver',
(candidate) =>
candidate.target === Character && candidate.propertyName === 'silver',
);
expect(silver).toBeDefined();
expect(silver?.options.type).toBe('integer');
const lootTableId = columns.find(
(candidate) =>
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId',
candidate.target === MonsterDefinition &&
candidate.propertyName === 'lootTableId',
);
expect(lootTableId).toBeDefined();
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', () => {
const dropChance = getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance',
candidate.target === LootTableEntry &&
candidate.propertyName === 'dropChance',
);
expect(dropChance?.options.type).toBe('numeric');
@@ -111,11 +164,19 @@ describe('loot and rewards schema', () => {
expect(upQueries).toEqual(
expect.arrayContaining([
// The database half of the "one reward per combat" invariant (spec §7, §37).
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat"',
),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_items_character_item"',
),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"',
),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'),
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'),
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 {
const index = getMetadataArgsStorage().indices.find(
(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;
}
@@ -52,7 +56,9 @@ describe('Slice 0.6.5 entity metadata', () => {
});
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', () => {

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ export class EquipmentController {
@Post()
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({
imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
TypeOrmModule.forFeature([
Character,
Combat,
CharacterItem,
ItemDefinition,
CharacterEquipment,
]),
CharactersModule,
],
controllers: [EquipmentController],

View File

@@ -38,17 +38,30 @@ class FakeRepository<T extends { id: string }> {
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
const row =
this.rows().find((candidate) => this.matches(candidate, options.where)) ??
null;
return Promise.resolve(
row ? this.withRelations(row, options.relations) : null,
);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
return Promise.resolve(
this.rows().find((row) => this.matches(row, where)) ?? null,
);
}
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
find(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
}): Promise<T[]> {
const matched = this.rows().filter((row) =>
this.matches(row, options.where),
);
return Promise.resolve(
matched.map((row) => this.withRelations(row, options.relations)),
);
}
create(values: Partial<T>): T {
@@ -75,12 +88,18 @@ class FakeRepository<T extends { id: string }> {
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
const itemDefinitionId = (row as unknown as CharacterItem)
.itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find(
(d) => d.id === itemDefinitionId,
);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
const characterItemId = (row as unknown as CharacterEquipment)
.characterItemId;
const characterItem = this.state.characterItems.find(
(ci) => ci.id === characterItemId,
);
copy['characterItem'] = characterItem
? {
...characterItem,
@@ -90,24 +109,30 @@ class FakeRepository<T extends { id: string }> {
}
: undefined;
}
return copy as T;
return copy;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === ItemDefinition)
return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
if (this.target === CharacterEquipment)
return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
}
}
@@ -119,7 +144,9 @@ class FakeDataSource {
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({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
@@ -133,7 +160,9 @@ class FakeDataSource {
}
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
function itemDefinition(
overrides: Partial<ItemDefinition> = {},
): ItemDefinition {
return {
id: 'def-worn-sword',
key: 'worn-short-sword',
@@ -152,7 +181,7 @@ function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as ItemDefinition;
};
}
function character(overrides: Partial<Character> = {}): Character {
@@ -176,12 +205,20 @@ function createHarness(state: Partial<State> = {}) {
...state,
};
const dataSource = new FakeDataSource(fullState);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
const service = new EquipmentService(dataSource as unknown as DataSource, characterStats);
const characterStats = new CharacterStatsService(
dataSource as unknown as DataSource,
);
const service = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
);
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;
try {
await promise;
@@ -265,7 +302,9 @@ describe('EquipmentService', () => {
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
expect(
state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID),
).toBeDefined();
});
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);
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 () => {
@@ -397,7 +438,13 @@ describe('EquipmentService', () => {
quantity: 1,
} 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(
@@ -422,7 +469,12 @@ describe('EquipmentService', () => {
FEET: 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 {
maxHp: number;
@@ -64,7 +67,10 @@ export class EquipmentService {
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15).
*/
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
async equip(
characterId: string,
characterItemId: string,
): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);

View File

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

View File

@@ -34,7 +34,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
}
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 = [
characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
@@ -44,7 +44,10 @@ describe('InventoryService', () => {
} as unknown as Repository<CharacterItem>;
const equipment = {
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>;
const service = new InventoryService(characterItems, equipment);
@@ -92,6 +95,8 @@ describe('InventoryService', () => {
} as unknown as Repository<CharacterEquipment>;
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`.
*/
@Entity({ name: 'character_items' })
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], {
unique: true,
})
@Index(
'IDX_character_items_character_item',
['characterId', 'itemDefinitionId'],
{
unique: true,
},
)
export class CharacterItem {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;

View File

@@ -25,7 +25,12 @@ export class ItemDefinition {
@Column({ name: 'description', type: 'text' })
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;
@Column({
@@ -37,7 +42,12 @@ export class ItemDefinition {
})
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;
@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).
*/
@Entity({ name: 'loot_table_entries' })
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true })
@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true })
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], {
unique: true,
})
@Index(
'IDX_loot_table_entries_table_item',
['lootTableId', 'itemDefinitionId'],
{ unique: true },
)
export class LootTableEntry {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;

View File

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

View File

@@ -30,7 +30,9 @@ function queuedRandom(...values: number[]): RandomSource {
return {
next: () => {
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++];
},
@@ -40,14 +42,15 @@ function queuedRandom(...values: number[]): RandomSource {
function dataSourceWith(entries: LootTableEntry[]): DataSource {
return {
getRepository: jest.fn(() => ({
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) =>
entries
.filter(
(candidate) =>
candidate.lootTableId === options.where.lootTableId &&
candidate.enabled === options.where.enabled,
)
.sort((a, b) => a.position - b.position),
find: jest.fn(
async (options: { where: { lootTableId: string; enabled: boolean } }) =>
entries
.filter(
(candidate) =>
candidate.lootTableId === options.where.lootTableId &&
candidate.enabled === options.where.enabled,
)
.sort((a, b) => a.position - b.position),
),
})),
} as unknown as DataSource;
@@ -55,7 +58,12 @@ function dataSourceWith(entries: LootTableEntry[]): DataSource {
describe('LootService', () => {
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({
id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD,
@@ -65,7 +73,10 @@ describe('LootService', () => {
];
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({
items: [
@@ -76,13 +87,21 @@ describe('LootService', () => {
});
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 () => {
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({
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 () => {
const outOfOrder = [
entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }),
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }),
entry({
id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD,
position: 2,
dropChance: '1.0000',
}),
entry({
id: 'entry-pelt',
itemDefinitionId: ASH_PELT,
position: 1,
dropChance: '0.0000',
}),
];
const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5));
const service = new LootService(
dataSourceWith(outOfOrder),
queuedRandom(0.5, 0.5),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
@@ -107,13 +139,20 @@ describe('LootService', () => {
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 () => {
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).
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({
items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }],
@@ -121,7 +160,10 @@ describe('LootService', () => {
});
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: [] });
});

View File

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

View File

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

View File

@@ -4,9 +4,11 @@ describe('RENOWN_BASE_STATS', () => {
it('spans exactly Renown 1 through 15', () => {
expect(RENOWN_MIN).toBe(1);
expect(RENOWN_MAX).toBe(15);
expect(Object.keys(RENOWN_BASE_STATS).map(Number).sort((a, b) => a - b)).toEqual(
Array.from({ length: 15 }, (_, i) => i + 1),
);
expect(
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', () => {

View File

@@ -5,7 +5,10 @@ export const RENOWN_MAX = 15;
// total base-stat progression (100 HP / 6 Attack -> 148 HP / 12 Attack)
// across Renown 1-15. RenownService looks this up on every milestone
// 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 },
2: { baseHp: 104, baseAttack: 6 },
3: { baseHp: 107, baseAttack: 7 },

View File

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

View File

@@ -21,15 +21,22 @@ class FakeRepository<T extends { id: string }> {
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) {
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> {
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 {
@@ -50,7 +57,9 @@ class FakeRepository<T extends { id: string }> {
}
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);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
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);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
private repoFor<T extends { id: string }>(
target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character)
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
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)
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');
}
}
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 {
id: MILESTONE_ID,
key: 'first-hunt',
@@ -105,10 +141,16 @@ function createState(overrides: Partial<State> = {}): State {
function createService(state: 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;
try {
await promise;
@@ -127,7 +169,10 @@ describe('RenownService', () => {
it('grants renown and recomputes base stats from the power-curve table', async () => {
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({
milestoneKey: 'first-hunt',
@@ -167,11 +212,16 @@ describe('RenownService', () => {
});
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);
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.newRenown).toBe(3);
@@ -186,7 +236,10 @@ describe('RenownService', () => {
});
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({
milestoneKey: 'first-hunt',
@@ -207,7 +260,9 @@ describe('RenownService', () => {
});
it('rejects a disabled milestone', async () => {
const state = createState({ milestones: [milestone({ enabled: false })] });
const state = createState({
milestones: [milestone({ enabled: false })],
});
const { service } = createService(state);
await expectRenownDomainError(

View File

@@ -33,7 +33,9 @@ export class RenownService {
milestoneKey: string,
manager?: EntityManager,
): Promise<RenownMilestoneResult> {
const run = async (txManager: EntityManager): Promise<RenownMilestoneResult> => {
const run = async (
txManager: EntityManager,
): Promise<RenownMilestoneResult> => {
const characters = txManager.getRepository(Character);
const milestones = txManager.getRepository(RenownMilestoneDefinition);
const completions = txManager.getRepository(CharacterRenownMilestone);
@@ -63,7 +65,10 @@ export class RenownService {
}
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;
if (renownGranted) {

View File

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

View File

@@ -7,7 +7,11 @@ export interface ReputationRankInfo {
// Verbatim thresholds from spec §10, German labels for the German-language UI.
// 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: 800, key: 'TRUSTED', label: 'Vertraut' },
{ 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 {
const index = REPUTATION_RANKS.findIndex((rank) => reputation >= rank.threshold);
const index = REPUTATION_RANKS.findIndex(
(rank) => reputation >= rank.threshold,
);
const rank = REPUTATION_RANKS[index];
const nextRank = index > 0 ? REPUTATION_RANKS[index - 1] : null;

View File

@@ -15,7 +15,9 @@ describe('ReputationController', () => {
getCharacterReputation.mockReset();
const module = await Test.createTestingModule({
controllers: [ReputationController],
providers: [{ provide: ReputationService, useValue: { getCharacterReputation } }],
providers: [
{ provide: ReputationService, useValue: { getCharacterReputation } },
],
}).compile();
app = module.createNestApplication<App>();
@@ -40,7 +42,9 @@ describe('ReputationController', () => {
];
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(response.body).toEqual(entries);

View File

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

View File

@@ -22,20 +22,29 @@ class FakeRepository<T extends { id: string }> {
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) {
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> {
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[]> {
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 {
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);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
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);
}
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)
return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
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)
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');
}
}
@@ -89,7 +118,9 @@ function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, ...overrides } as Character;
}
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction {
function faction(
overrides: Partial<ReputationFaction> = {},
): ReputationFaction {
return {
id: FACTION_ID,
key: 'border-guard',
@@ -102,15 +133,26 @@ function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction
}
function createState(overrides: Partial<State> = {}): State {
return { characters: [character()], factions: [faction()], characterReputation: [], ...overrides };
return {
characters: [character()],
factions: [faction()],
characterReputation: [],
...overrides,
};
}
function createService(state: 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;
try {
await promise;
@@ -154,7 +196,11 @@ describe('ReputationService', () => {
it('creates a reputation row starting from 0 on the first grant', async () => {
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({
factionKey: 'border-guard',
@@ -171,12 +217,21 @@ describe('ReputationService', () => {
it('accumulates onto an existing reputation row', async () => {
const state = createState({
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 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.newReputation).toBe(110);
@@ -185,12 +240,21 @@ describe('ReputationService', () => {
it('reports rankChanged when a grant crosses a threshold', async () => {
const state = createState({
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 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.newRank).toBe('TOLERATED');
@@ -200,12 +264,21 @@ describe('ReputationService', () => {
it('reports rankChanged: false when a grant stays within the same rank', async () => {
const state = createState({
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 result = await service.grantReputation(CHARACTER_ID, 'border-guard', 10);
const result = await service.grantReputation(
CHARACTER_ID,
'border-guard',
10,
);
expect(result.rankChanged).toBe(false);
});

View File

@@ -3,7 +3,10 @@ import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterReputation } from './entities/character-reputation.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';
export interface ReputationGrantResult {
@@ -37,7 +40,9 @@ export class ReputationService {
amount: number,
manager?: EntityManager,
): Promise<ReputationGrantResult> {
const run = async (txManager: EntityManager): Promise<ReputationGrantResult> => {
const run = async (
txManager: EntityManager,
): Promise<ReputationGrantResult> => {
const characters = txManager.getRepository(Character);
const factions = txManager.getRepository(ReputationFaction);
const reputations = txManager.getRepository(CharacterReputation);
@@ -74,7 +79,11 @@ export class ReputationService {
await reputations.save(existing);
} else {
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,
* 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 factions = await scope.getRepository(ReputationFaction).find({ where: { enabled: true } });
const reputations = await scope.getRepository(CharacterReputation).find({ where: { characterId } });
const factions = await scope
.getRepository(ReputationFaction)
.find({ where: { enabled: true } });
const reputations = await scope
.getRepository(CharacterReputation)
.find({ where: { characterId } });
return factions.map((faction) => {
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> {
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> {
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: {
@@ -57,7 +61,10 @@ class FakeRepository<T extends { id: string }> {
// Mirrors TypeORM's `order` clause so tests can prove ordering comes from
// the query, not from insertion order happening to line up.
const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC'];
const [key, direction] = Object.entries(options.order)[0] as [
keyof T,
'ASC' | 'DESC',
];
const sorted = [...matched].sort((a, b) => {
const left = a[key];
const right = b[key];
@@ -85,23 +92,33 @@ class FakeRepository<T extends { id: string }> {
}
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 {
return {
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
if (target === Character) return new FakeRepository(state.characters, 'character') as never;
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never;
if (target === Character)
return new FakeRepository(state.characters, 'character') as never;
if (target === MonsterDefinition)
return new FakeRepository(state.monsters, 'monster') as never;
if (target === ItemDefinition)
return new FakeRepository(state.itemDefinitions, 'definition') as never;
if (target === CharacterItem)
return new FakeRepository(state.characterItems, 'character-item') as never;
return new FakeRepository(
state.characterItems,
'character-item',
) as never;
if (target === CombatReward)
return new FakeRepository(state.combatRewards, 'reward') as never;
if (target === CombatRewardItem)
return new FakeRepository(state.combatRewardItems, 'reward-item') as never;
return new FakeRepository(
state.combatRewardItems,
'reward-item',
) as never;
throw new Error('Unsupported repository');
},
} as unknown as EntityManager;
@@ -162,8 +179,12 @@ function fakeDataSource(state: State): EntityManager {
return fakeManager(state);
}
function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService {
return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService;
function fakeLoot(
...items: Array<{ itemDefinitionId: string; quantity: number }>
): LootService {
return {
rollLoot: jest.fn().mockResolvedValue({ items }),
} as unknown as LootService;
}
function fixedRandom(value: number): RandomSource {
@@ -184,7 +205,10 @@ describe('CombatRewardService', () => {
const state = createState();
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' });
expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].silver).toBe(3);
@@ -194,7 +218,10 @@ describe('CombatRewardService', () => {
const state = createState();
await expect(
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })),
service(state).grantVictoryRewards(
fakeManager(state),
combat({ status: CombatStatus.LOST }),
),
).rejects.toBeInstanceOf(RewardDomainError);
expect(state.combatRewards).toHaveLength(0);
});
@@ -202,7 +229,10 @@ describe('CombatRewardService', () => {
it('grants rewards for a WON combat', async () => {
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(state.combatRewards).toHaveLength(1);
@@ -213,10 +243,11 @@ describe('CombatRewardService', () => {
it('grants a silver roll inside 4-7, persisted on the character', async () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
fakeManager(state),
combat(),
);
const reward = await service(
state,
fakeLoot(),
fixedRandom(0),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(4);
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 () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards(
fakeManager(state),
combat(),
);
const reward = await service(
state,
fakeLoot(),
fixedRandom(0.99),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(7);
});
@@ -240,10 +272,11 @@ describe('CombatRewardService', () => {
it('grants a silver roll inside 9-15', async () => {
const state = createState();
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
fakeManager(state),
banditCombat,
);
const reward = await service(
state,
fakeLoot(),
fixedRandom(0),
).grantVictoryRewards(fakeManager(state), banditCombat);
expect(reward.silver).toBe(9);
});
@@ -361,7 +394,11 @@ describe('CombatRewardService', () => {
const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const dataSource = fakeDataSource(state);
const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0));
const subject = new CombatRewardService(
dataSource as never,
loot,
fixedRandom(0),
);
const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, combat());
@@ -379,10 +416,10 @@ describe('CombatRewardService', () => {
const state = createState();
await expect(
service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards(
fakeManager(state),
combat(),
),
service(
state,
fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 }),
).grantVictoryRewards(fakeManager(state), combat()),
).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' });
expect(state.combatRewardItems).toHaveLength(0);
// Every rolled item definition is resolved before any mutation, so a
@@ -430,7 +467,9 @@ describe('CombatRewardService', () => {
const expectedKeys = ['bandit-hood', 'bandit-blade'];
expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys);
expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys);
expect(replayed?.items.map((item) => item.item.key)).toEqual(
expectedKeys,
);
expect(replayed).toEqual(granted);
});
});

View File

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

View File

@@ -48,14 +48,23 @@ describe('TurnInController', () => {
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 })
.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);
});
it('rejects server-owned reward fields the client must never send', async () => {
await request(app.getHttpServer())
.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(turnIn).not.toHaveBeenCalled();

View File

@@ -9,6 +9,10 @@ export class TurnInController {
@Post()
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({
imports: [
TypeOrmModule.forFeature([Character, CharacterItem, TurnInDefinition, ReputationFaction]),
TypeOrmModule.forFeature([
Character,
CharacterItem,
TurnInDefinition,
ReputationFaction,
]),
ReputationModule,
],
controllers: [TurnInController],

View File

@@ -29,20 +29,29 @@ class FakeRepository<T extends { id: string }> {
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) {
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> {
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[]> {
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 {
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);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
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);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
private repoFor<T extends { id: string }>(
target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character)
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
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)
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)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never;
return new FakeRepository(
this.state.factions,
'faction',
inTransaction,
) as never;
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');
}
}
@@ -117,7 +155,9 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
} as CharacterItem;
}
function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefinition {
function turnInDefinition(
overrides: Partial<TurnInDefinition> = {},
): TurnInDefinition {
return {
id: 'turn-in-1',
key: 'ash-pelt-border-guard',
@@ -131,8 +171,16 @@ function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefi
} as TurnInDefinition;
}
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction {
return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', enabled: true, ...overrides } as ReputationFaction;
function faction(
overrides: Partial<ReputationFaction> = {},
): ReputationFaction {
return {
id: FACTION_ID,
key: 'border-guard',
name: 'Grenzwacht',
enabled: true,
...overrides,
} as ReputationFaction;
}
function createState(overrides: Partial<State> = {}): State {
@@ -148,14 +196,22 @@ function createState(overrides: Partial<State> = {}): State {
function createService(state: 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 {
service: new TurnInService(dataSource as unknown as DataSource, reputationService),
service: new TurnInService(
dataSource as unknown as DataSource,
reputationService,
),
state,
};
}
async function expectTurnInDomainError(promise: Promise<unknown>, code: string): Promise<void> {
async function expectTurnInDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
@@ -198,7 +254,11 @@ describe('TurnInService', () => {
it('consumes the exact quantity, grants silver and reputation atomically', async () => {
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({
turnInKey: 'ash-pelt-border-guard',
@@ -219,7 +279,9 @@ describe('TurnInService', () => {
});
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);
@@ -227,7 +289,9 @@ describe('TurnInService', () => {
});
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(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3),
@@ -271,7 +335,9 @@ describe('TurnInService', () => {
});
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);
await expectTurnInDomainError(
@@ -282,11 +348,20 @@ describe('TurnInService', () => {
it('computes multi-item rewards server-side from the definition, not from client input', async () => {
const state = createState({
turnIns: [turnInDefinition({ silverRewardPerItem: 12, reputationRewardPerItem: 4 })],
turnIns: [
turnInDefinition({
silverRewardPerItem: 12,
reputationRewardPerItem: 4,
}),
],
});
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.reputationResult.newReputation).toBe(20);

View File

@@ -3,7 +3,10 @@ import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.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 {
characterNotFound,
@@ -33,7 +36,11 @@ export class TurnInService {
* untouched. The reward math is entirely server-computed from persisted
* 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) {
throw turnInInvalidQuantity();
}
@@ -90,7 +97,12 @@ export class TurnInService {
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 () => {
const completeTravelIfDue = jest
.fn()
.mockResolvedValue({ status: 'IDLE' });
const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' });
const service = createService(
location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
completeTravelIfDue,
@@ -155,6 +153,8 @@ describe('WorldService.runLocalInteraction', () => {
it('rejects a navigation hotspot that has no result to reveal', async () => {
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', () => {
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({
getCurrentLocation,
} as unknown as WorldService);

View File

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