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:
@@ -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 } },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +1001,9 @@ 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) => {
|
||||
grantVictoryRewards: jest
|
||||
.fn()
|
||||
.mockImplementation(async (manager: EntityManager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const combatCharacter = await characters.findOneBy({
|
||||
id: CHARACTER_ID,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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 \'\'',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"',
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"'),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,13 @@ import { ItemDefinition } from './item-definition.entity';
|
||||
* `ItemDefinition.id`.
|
||||
*/
|
||||
@Entity({ name: 'character_items' })
|
||||
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], {
|
||||
@Index(
|
||||
'IDX_character_items_character_item',
|
||||
['characterId', 'itemDefinitionId'],
|
||||
{
|
||||
unique: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
export class CharacterItem {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,7 +42,8 @@ function queuedRandom(...values: number[]): RandomSource {
|
||||
function dataSourceWith(entries: LootTableEntry[]): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn(() => ({
|
||||
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) =>
|
||||
find: jest.fn(
|
||||
async (options: { where: { lootTableId: string; enabled: boolean } }) =>
|
||||
entries
|
||||
.filter(
|
||||
(candidate) =>
|
||||
@@ -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: [] });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'], {
|
||||
@Index(
|
||||
'IDX_character_renown_milestones_character_milestone',
|
||||
['characterId', 'milestoneId'],
|
||||
{
|
||||
unique: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
export class CharacterRenownMilestone {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'], {
|
||||
@Index(
|
||||
'IDX_character_reputation_character_faction',
|
||||
['characterId', 'factionId'],
|
||||
{
|
||||
unique: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
export class CharacterReputation {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +195,7 @@ 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({
|
||||
const rewardItems = await scope.getRepository(CombatRewardItem).find({
|
||||
where: { combatRewardId: reward.id },
|
||||
order: { itemDefinitionId: 'ASC' },
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'], {
|
||||
@Index(
|
||||
'IDX_combat_reward_items_reward_item',
|
||||
['combatRewardId', 'itemDefinitionId'],
|
||||
{
|
||||
unique: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
export class CombatRewardItem {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user