fix(monsters): finish deleting experienceReward, column included

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

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

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

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

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

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

View File

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

View File

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

View File

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