From e5746dec5c66d55368dfcb6fdb22af54693c7594 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:26:33 +0200 Subject: [PATCH] feat(api): retire CharacterCombatStatsService; characters/me returns effective stats --- .../character-combat-stats.service.spec.ts | 16 -------- .../character-combat-stats.service.ts | 28 ------------- apps/api/src/characters/characters.module.ts | 6 +-- .../src/characters/characters.service.spec.ts | 40 +++++++++++++------ apps/api/src/characters/characters.service.ts | 8 +++- 5 files changed, 37 insertions(+), 61 deletions(-) delete mode 100644 apps/api/src/characters/character-combat-stats.service.spec.ts delete mode 100644 apps/api/src/characters/character-combat-stats.service.ts diff --git a/apps/api/src/characters/character-combat-stats.service.spec.ts b/apps/api/src/characters/character-combat-stats.service.spec.ts deleted file mode 100644 index 2cb2279..0000000 --- a/apps/api/src/characters/character-combat-stats.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CharacterCombatStatsService } from './character-combat-stats.service'; -import { Character } from './entities/character.entity'; - -describe('CharacterCombatStatsService', () => { - it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => { - const service = new CharacterCombatStatsService(); - const character = { baseHp: 100, baseAttack: 6 } as Character; - - expect(service.getStats(character)).toEqual({ - maxHp: 100, - attack: 6, - weaponDamage: 8, - armor: 6, - }); - }); -}); diff --git a/apps/api/src/characters/character-combat-stats.service.ts b/apps/api/src/characters/character-combat-stats.service.ts deleted file mode 100644 index 665d893..0000000 --- a/apps/api/src/characters/character-combat-stats.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Character } from './entities/character.entity'; - -export interface CharacterCombatStats { - maxHp: number; - attack: number; - weaponDamage: number; - armor: number; -} - -// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants -// stand in for the starting weapon/armor until Slice 0.5 introduces real -// equipment. Replacing them there must not change this method's signature -// or the combat API it feeds (spec §10). -const TEMPORARY_WEAPON_DAMAGE = 8; -const TEMPORARY_ARMOR = 6; - -@Injectable() -export class CharacterCombatStatsService { - getStats(character: Character): CharacterCombatStats { - return { - maxHp: character.baseHp, - attack: character.baseAttack, - weaponDamage: TEMPORARY_WEAPON_DAMAGE, - armor: TEMPORARY_ARMOR, - }; - } -} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts index f8a46c1..af53989 100644 --- a/apps/api/src/characters/characters.module.ts +++ b/apps/api/src/characters/characters.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CharacterCombatStatsService } from './character-combat-stats.service'; +import { CharacterStatsService } from './character-stats.service'; import { CharactersController } from './characters.controller'; import { CharactersService } from './characters.service'; import { Character } from './entities/character.entity'; @@ -8,7 +8,7 @@ import { Character } from './entities/character.entity'; @Module({ imports: [TypeOrmModule.forFeature([Character])], controllers: [CharactersController], - providers: [CharactersService, CharacterCombatStatsService], - exports: [CharacterCombatStatsService], + providers: [CharactersService, CharacterStatsService], + exports: [CharacterStatsService], }) export class CharactersModule {} diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts index 63940ed..0cddc32 100644 --- a/apps/api/src/characters/characters.service.spec.ts +++ b/apps/api/src/characters/characters.service.spec.ts @@ -2,11 +2,27 @@ import { NotFoundException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants'; +import { CharacterStatsService } from './character-stats.service'; import { Character } from './entities/character.entity'; import { CharactersService } from './characters.service'; +function fakeCharacterStats( + overrides: Partial<{ maxHp: number; attack: number }> = {}, +): CharacterStatsService { + return { + calculate: jest.fn().mockResolvedValue({ + maxHp: overrides.maxHp ?? 100, + currentHp: 100, + attack: overrides.attack ?? 6, + weaponDamage: 8, + armor: 0, + combatPower: 0, + }), + } as unknown as CharacterStatsService; +} + describe('CharactersService', () => { - it('returns the demo character with its current location summary', async () => { + it('returns the demo character with effective attack/HP and its location summary', async () => { const repository = { findOne: jest.fn().mockResolvedValue({ id: DEMO_CHARACTER_ID, @@ -20,11 +36,12 @@ describe('CharactersService', () => { currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 }); + const service = new CharactersService(repository, characterStats); await expect(service.getDemoCharacter()).resolves.toEqual({ id: DEMO_CHARACTER_ID, @@ -33,18 +50,17 @@ describe('CharactersService', () => { experience: 0, silver: 0, currentHp: 100, - maxHp: 100, - attack: 6, + maxHp: 115, + attack: 7, currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: DEMO_CHARACTER_ID }, - relations: { currentLocation: true }, - }); + expect(characterStats.calculate).toHaveBeenCalledWith( + expect.objectContaining({ id: DEMO_CHARACTER_ID }), + ); }); it('exposes the persisted silver so the HUD never has to guess', async () => { @@ -65,7 +81,7 @@ describe('CharactersService', () => { }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).resolves.toEqual( expect.objectContaining({ experience: 24, silver: 18 }), @@ -76,7 +92,7 @@ describe('CharactersService', () => { const repository = { findOne: jest.fn().mockResolvedValue(null), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index ce5a099..10ed4dc 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { CharacterStatsService } from './character-stats.service'; import { Character } from './entities/character.entity'; @Injectable() @@ -9,6 +10,7 @@ export class CharactersService { constructor( @InjectRepository(Character) private readonly characters: Repository, + private readonly characterStats: CharacterStatsService, ) {} async getDemoCharacter() { @@ -21,6 +23,8 @@ export class CharactersService { throw new NotFoundException('Demo character has not been seeded'); } + const stats = await this.characterStats.calculate(character); + return { id: character.id, name: character.name, @@ -28,8 +32,8 @@ export class CharactersService { experience: character.experience, silver: character.silver, currentHp: character.currentHp, - maxHp: character.baseHp, - attack: character.baseAttack, + maxHp: stats.maxHp, + attack: stats.attack, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key,