feat(api): add CharacterStatsService as authoritative effective-stat source
This commit is contained in:
115
apps/api/src/characters/character-stats.service.spec.ts
Normal file
115
apps/api/src/characters/character-stats.service.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// apps/api/src/characters/character-stats.service.spec.ts
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
|
||||
type EquippedFixture = {
|
||||
slot: EquipmentSlot;
|
||||
item: Partial<ItemDefinition>;
|
||||
};
|
||||
|
||||
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
|
||||
const rows = equipped.map((entry) => ({
|
||||
slot: entry.slot,
|
||||
characterItem: {
|
||||
itemDefinition: {
|
||||
weaponDamage: 0,
|
||||
bonusHp: 0,
|
||||
bonusAttack: 0,
|
||||
bonusArmor: 0,
|
||||
...entry.item,
|
||||
},
|
||||
},
|
||||
}));
|
||||
return {
|
||||
getRepository: () => ({ find: async () => rows }) as never,
|
||||
} as unknown as Pick<DataSource, 'getRepository'>;
|
||||
}
|
||||
|
||||
function character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'character-1',
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 90,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
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 stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.attack).toBe(6);
|
||||
expect(stats.weaponDamage).toBe(8);
|
||||
expect(stats.maxHp).toBe(100);
|
||||
expect(stats.armor).toBe(0);
|
||||
});
|
||||
|
||||
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.attack).toBe(7);
|
||||
expect(stats.weaponDamage).toBe(11);
|
||||
});
|
||||
|
||||
it('sums bonusArmor across multiple equipped armor pieces', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
|
||||
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.armor).toBe(10);
|
||||
});
|
||||
|
||||
it('sums bonusHp across equipped items on top of base HP', async () => {
|
||||
const scope = fakeScope([
|
||||
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
|
||||
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.maxHp).toBe(115);
|
||||
});
|
||||
|
||||
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
|
||||
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
expect(stats.weaponDamage).toBe(0);
|
||||
});
|
||||
|
||||
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.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
|
||||
]);
|
||||
|
||||
const stats = await service.calculate(character(), scope);
|
||||
|
||||
// maxHp=105, attack=7, weaponDamage=11, armor=3
|
||||
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||
});
|
||||
|
||||
it('passes currentHp through unchanged from the character', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const stats = await service.calculate(character({ currentHp: 42 }), scope);
|
||||
|
||||
expect(stats.currentHp).toBe(42);
|
||||
});
|
||||
});
|
||||
64
apps/api/src/characters/character-stats.service.ts
Normal file
64
apps/api/src/characters/character-stats.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface EffectiveCharacterStats {
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
attack: number;
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
combatPower: number;
|
||||
}
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
|
||||
/**
|
||||
* Single authoritative source of effective character stats (spec §18).
|
||||
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterStatsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async calculate(
|
||||
character: Character,
|
||||
scope?: RepositoryScope,
|
||||
): Promise<EffectiveCharacterStats> {
|
||||
const db = scope ?? this.dataSource;
|
||||
const equipped = await db.getRepository(CharacterEquipment).find({
|
||||
where: { characterId: character.id },
|
||||
relations: { characterItem: { itemDefinition: true } },
|
||||
});
|
||||
|
||||
let weaponDamage = 0;
|
||||
let bonusHp = 0;
|
||||
let bonusAttack = 0;
|
||||
let bonusArmor = 0;
|
||||
|
||||
for (const slot of equipped) {
|
||||
const definition = slot.characterItem.itemDefinition;
|
||||
if (slot.slot === EquipmentSlot.WEAPON) {
|
||||
weaponDamage = definition.weaponDamage;
|
||||
}
|
||||
bonusHp += definition.bonusHp;
|
||||
bonusAttack += definition.bonusAttack;
|
||||
bonusArmor += definition.bonusArmor;
|
||||
}
|
||||
|
||||
const maxHp = character.baseHp + bonusHp;
|
||||
const attack = character.baseAttack + bonusAttack;
|
||||
const armor = bonusArmor;
|
||||
|
||||
return {
|
||||
maxHp,
|
||||
currentHp: character.currentHp,
|
||||
attack,
|
||||
weaponDamage,
|
||||
armor,
|
||||
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user