fix(monsters): finish deleting experienceReward, column included

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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