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