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:
@@ -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,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user