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