diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts new file mode 100644 index 0000000..45f8417 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -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; +}; + +function fakeScope(equipped: EquippedFixture[]): Pick { + 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; +} + +function character(overrides: Partial = {}): 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); + }); +}); diff --git a/apps/api/src/characters/character-stats.service.ts b/apps/api/src/characters/character-stats.service.ts new file mode 100644 index 0000000..5d0e0a1 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.ts @@ -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; + +/** + * 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 { + 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, + }; + } +}