Merge branch 'master' into worktree-playable-slice-0.6.5-renown-reputation
Reconciles Slice 0.6.5 (Renown & Reputation Foundation) against master's persistent-HP-and-regeneration slice, which landed independently and touches several of the same files (Character entity, CombatService, EquipmentService, the inventory detail panel). Conflict resolutions: - CharacterStatsService/EquipmentService constructor wiring: kept master's CharacterVitalsService injection, which this branch's version of the same files didn't have yet. - CombatService.performAction: kept master's HP-guard logic (characterTooWounded, vitals pause-on-enter) alongside this branch's multi-line calculate() call style. - Inventory detail panel (.html/.ts/.scss/.spec.ts): master had redesigned the panel (wrapping section, rarity styling, flavour text, a shared inventory.labels.ts) on top of the OLD level-gated component, since this branch's removal of the level gate (R4, Task 8/14) hadn't reached master yet. Kept master's visual redesign in full, but with the level-gate concept removed throughout: no requiredLevel stat block, no meetsLevelRequirement() branch in the equip button, no now-dead .detail__value--unmet SCSS rule. Kept both branches' independent tests (non-equippable-item, flavour-text). - inventory-page.component.ts: dropped master's dead characterLevel computed (nothing in the template read it, and the level concept is gone); kept its independent bagCells/bagUsed/bagCapacity grid feature, which has nothing to do with renown or level. Post-merge fixture repairs (three files failed the Angular bundle compile because they predate master's hpRegenPerSecond/hpRegenSince fields or master's item description field, neither conflict-marked since git considered them non-overlapping edits): - app.spec.ts: a 'renders loaded character values' test added on master after this branch forked still used the abolished level/ experience fields on its decoy fixture -- retargeted to renown. - inventory-detail-panel.component.spec.ts: the ashPelt fixture added by this branch's final-review follow-up predates master's required description field. - top-bar.component.spec.ts: this branch's fixture predates master's required hpRegenPerSecond/hpRegenSince fields. No database migration touches the same column: master's 1792000000000-AddHpRegeneration only adds characters.hp_regen_since, independent of this slice's 1791000000000-CreateRenownAndReputation. Timestamp ordering between the two was already correct with no rename needed. Verified: API 288/288 (267 from this slice + 21 from master), API build zero errors, web 237/237 (230 from this slice + 7 from master). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ 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';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
|
||||
type EquippedFixture = {
|
||||
slot: EquipmentSlot;
|
||||
@@ -36,12 +37,16 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 90,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
describe('CharacterStatsService', () => {
|
||||
const service = new CharacterStatsService({} as DataSource);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-21T12:00:00.000Z'),
|
||||
});
|
||||
const service = new CharacterStatsService({} as DataSource, characterVitals);
|
||||
|
||||
it('derives stats from the starting weapon alone', async () => {
|
||||
const scope = fakeScope([
|
||||
@@ -117,11 +122,46 @@ describe('CharacterStatsService', () => {
|
||||
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||
});
|
||||
|
||||
it('passes currentHp through unchanged from the character', async () => {
|
||||
it('returns the raw current HP unchanged while regeneration is paused', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const stats = await service.calculate(character({ currentHp: 42 }), scope);
|
||||
const stats = await service.calculate(
|
||||
character({ currentHp: 42, hpRegenSince: null }),
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(stats.currentHp).toBe(42);
|
||||
});
|
||||
|
||||
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const regenerating = await service.calculate(
|
||||
character({
|
||||
currentHp: 40,
|
||||
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
|
||||
}),
|
||||
scope,
|
||||
);
|
||||
expect(regenerating.currentHp).toBe(70);
|
||||
|
||||
const clamped = await service.calculate(
|
||||
character({
|
||||
currentHp: 40,
|
||||
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
|
||||
}),
|
||||
scope,
|
||||
);
|
||||
expect(clamped.currentHp).toBe(100);
|
||||
});
|
||||
|
||||
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
|
||||
const scope = fakeScope([]);
|
||||
const anchor = new Date('2026-08-21T11:59:30.000Z');
|
||||
|
||||
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
|
||||
|
||||
expect(stats.hpRegenPerSecond).toBe(1);
|
||||
expect(stats.hpRegenSince).toEqual(anchor);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ 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 { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface EffectiveCharacterStats {
|
||||
@@ -11,6 +13,8 @@ export interface EffectiveCharacterStats {
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
combatPower: number;
|
||||
hpRegenPerSecond: number;
|
||||
hpRegenSince: Date | null;
|
||||
}
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
@@ -21,7 +25,10 @@ type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterStatsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
) {}
|
||||
|
||||
async calculate(
|
||||
character: Character,
|
||||
@@ -54,11 +61,13 @@ export class CharacterStatsService {
|
||||
|
||||
return {
|
||||
maxHp,
|
||||
currentHp: character.currentHp,
|
||||
currentHp: this.characterVitals.effectiveHp(character, maxHp),
|
||||
attack,
|
||||
weaponDamage,
|
||||
armor,
|
||||
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||
hpRegenPerSecond: HP_REGEN_PER_SECOND,
|
||||
hpRegenSince: character.hpRegenSince,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const HP_REGEN_PER_SECOND = 1;
|
||||
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Clock } from '../shared/clock';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
function fakeClock(initialIso: string): {
|
||||
clock: Clock;
|
||||
advanceSeconds: (seconds: number) => void;
|
||||
} {
|
||||
let current = Date.parse(initialIso);
|
||||
return {
|
||||
clock: { now: () => new Date(current) },
|
||||
advanceSeconds: (seconds: number) => {
|
||||
current += seconds * 1000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'character-1',
|
||||
currentHp: 50,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
describe('CharacterVitalsService', () => {
|
||||
describe('effectiveHp', () => {
|
||||
it('returns the raw current HP when regeneration is paused', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
|
||||
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100);
|
||||
|
||||
expect(hp).toBe(37);
|
||||
});
|
||||
|
||||
it('adds one HP per elapsed second since the anchor', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(25);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(65);
|
||||
});
|
||||
|
||||
it('floors partial seconds instead of rounding up', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(1.9);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(41);
|
||||
});
|
||||
|
||||
it('clamps regeneration at maxHp', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 90, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(50);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('never lets HP fall if the clock moves backwards', () => {
|
||||
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('freezes current HP at the given value and clears the anchor', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const target = character({
|
||||
currentHp: 100,
|
||||
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
service.pause(target, 62);
|
||||
|
||||
expect(target.currentHp).toBe(62);
|
||||
expect(target.hpRegenSince).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume', () => {
|
||||
it('sets current HP and anchors regeneration at now', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const target = character({ currentHp: 0, hpRegenSince: null });
|
||||
|
||||
service.resume(target, 15);
|
||||
|
||||
expect(target.currentHp).toBe(15);
|
||||
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('settle', () => {
|
||||
it('re-anchors at the current effective value without changing it', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
advanceSeconds(10);
|
||||
|
||||
service.settle(target, 100);
|
||||
|
||||
expect(target.currentHp).toBe(50);
|
||||
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
|
||||
});
|
||||
|
||||
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 100, hpRegenSince: anchor });
|
||||
advanceSeconds(600);
|
||||
|
||||
service.settle(target, 100);
|
||||
|
||||
expect(target.currentHp).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/api/src/characters/character-vitals.service.ts
Normal file
46
apps/api/src/characters/character-vitals.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CLOCK } from '../shared/clock';
|
||||
import type { Clock } from '../shared/clock';
|
||||
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
/**
|
||||
* The only place that turns (current_hp, hp_regen_since) into an effective
|
||||
* HP value, or moves that pair. `current_hp` is exact only while the anchor
|
||||
* is null; everything else must go through here (persistent-hp-and-
|
||||
* regeneration design, R3).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterVitalsService {
|
||||
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
|
||||
|
||||
effectiveHp(
|
||||
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
|
||||
maxHp: number,
|
||||
): number {
|
||||
if (character.hpRegenSince === null) {
|
||||
return Math.min(maxHp, character.currentHp);
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(
|
||||
0,
|
||||
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
|
||||
);
|
||||
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
|
||||
return Math.min(maxHp, character.currentHp + regenerated);
|
||||
}
|
||||
|
||||
pause(character: Character, value: number): void {
|
||||
character.currentHp = value;
|
||||
character.hpRegenSince = null;
|
||||
}
|
||||
|
||||
resume(character: Character, value: number): void {
|
||||
character.currentHp = value;
|
||||
character.hpRegenSince = this.clock.now();
|
||||
}
|
||||
|
||||
settle(character: Character, maxHp: number): void {
|
||||
this.resume(character, this.effectiveHp(character, maxHp));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CLOCK, systemClock } from '../shared/clock';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { CharactersController } from './characters.controller';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
@@ -8,7 +10,12 @@ import { Character } from './entities/character.entity';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Character])],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService, CharacterStatsService],
|
||||
exports: [CharacterStatsService],
|
||||
providers: [
|
||||
CharactersService,
|
||||
CharacterStatsService,
|
||||
CharacterVitalsService,
|
||||
{ provide: CLOCK, useValue: systemClock },
|
||||
],
|
||||
exports: [CharacterStatsService, CharacterVitalsService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
|
||||
@@ -17,6 +17,8 @@ function fakeCharacterStats(
|
||||
weaponDamage: 8,
|
||||
armor: 0,
|
||||
combatPower: 0,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
} as unknown as CharacterStatsService;
|
||||
}
|
||||
@@ -50,6 +52,8 @@ describe('CharactersService', () => {
|
||||
currentHp: 100,
|
||||
maxHp: 115,
|
||||
attack: 7,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: '2026-08-18T09:00:00.000Z',
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
|
||||
@@ -30,9 +30,11 @@ export class CharactersService {
|
||||
name: character.name,
|
||||
renown: character.renown,
|
||||
silver: character.silver,
|
||||
currentHp: character.currentHp,
|
||||
currentHp: stats.currentHp,
|
||||
maxHp: stats.maxHp,
|
||||
attack: stats.attack,
|
||||
hpRegenPerSecond: stats.hpRegenPerSecond,
|
||||
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null,
|
||||
currentLocation: {
|
||||
id: character.currentLocation.id,
|
||||
key: character.currentLocation.key,
|
||||
|
||||
@@ -32,6 +32,12 @@ export class Character {
|
||||
@Column({ name: 'current_hp', type: 'integer' })
|
||||
currentHp!: number;
|
||||
|
||||
// `current_hp` is only exact while this is null (regeneration paused, e.g.
|
||||
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
|
||||
// through CharacterVitalsService.effectiveHp(), never directly.
|
||||
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
|
||||
hpRegenSince!: Date | null;
|
||||
|
||||
@Column({ name: 'current_location_id', type: 'uuid' })
|
||||
currentLocationId!: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentService } from '../equipment/equipment.service';
|
||||
@@ -194,6 +195,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
@@ -335,18 +337,24 @@ function createHarness() {
|
||||
],
|
||||
};
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const characterStats = new CharacterStatsService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterVitals,
|
||||
);
|
||||
const equipmentService = new EquipmentService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterStats,
|
||||
characterVitals,
|
||||
);
|
||||
const combatService = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
characterStats,
|
||||
characterVitals,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { state, equipmentService, combatService };
|
||||
|
||||
@@ -5,6 +5,7 @@ export type CombatErrorCode =
|
||||
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
||||
| 'INVALID_HUNT_ENCOUNTER'
|
||||
| 'CHARACTER_TRAVELLING'
|
||||
| 'CHARACTER_TOO_WOUNDED'
|
||||
| 'COMBAT_ALREADY_ACTIVE'
|
||||
| 'COMBAT_NOT_FOUND'
|
||||
| 'COMBAT_ALREADY_FINISHED'
|
||||
@@ -53,6 +54,14 @@ export function characterTravelling(): CombatDomainError {
|
||||
);
|
||||
}
|
||||
|
||||
export function characterTooWounded(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'CHARACTER_TOO_WOUNDED',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character is too wounded to fight.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatAlreadyActive(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_ALREADY_ACTIVE',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -178,6 +179,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
@@ -283,11 +285,15 @@ function createService(
|
||||
const travelService = options.travelService ?? fakeTravelService();
|
||||
const combatEngine = new CombatEngineService();
|
||||
const characterCombatStats = fakeCharacterStats();
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
travelService,
|
||||
combatEngine,
|
||||
characterCombatStats,
|
||||
characterVitals,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { dataSource, service, travelService };
|
||||
@@ -347,6 +353,49 @@ describe('CombatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 63 })] });
|
||||
const { dataSource, service } = createService({ state });
|
||||
|
||||
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(combat.player.currentHp).toBe(63);
|
||||
expect(dataSource.state.combats[0].playerCurrentHp).toBe(63);
|
||||
});
|
||||
|
||||
it('pauses regeneration on the character once a combat starts', async () => {
|
||||
const state = createState({
|
||||
characters: [
|
||||
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }),
|
||||
],
|
||||
});
|
||||
const { dataSource, service } = createService({ state });
|
||||
|
||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(63);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects starting a combat when the character has 0 effective HP', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 0 })] });
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'CHARACTER_TOO_WOUNDED',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows starting a combat at exactly 1 effective HP', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 1 })] });
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expect(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('marks the encounter as IN_PROGRESS', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
@@ -528,6 +577,41 @@ describe('CombatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors the player HP onto the character each round while the fight continues', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(95);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('restarts regeneration on the character once the fight is won', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(
|
||||
dataSource.state.combats[0].playerCurrentHp,
|
||||
);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||
new Date('2026-08-18T09:00:00.000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts regeneration from 0 HP once the fight is lost', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(0);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||
new Date('2026-08-18T09:00:00.000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
@@ -548,7 +632,7 @@ describe('CombatService', () => {
|
||||
|
||||
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||
});
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
@@ -579,7 +663,7 @@ describe('CombatService', () => {
|
||||
});
|
||||
|
||||
it('frees the encounter for another attempt when the fight is lost', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
@@ -599,11 +683,19 @@ describe('CombatService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a lost encounter be fought again as a fresh combat', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
it('lets a lost encounter be fought again once the character has recovered HP', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
// The loss now mirrors 0 HP onto the character (this task); simulate
|
||||
// that regeneration has since restored it before retrying. This test
|
||||
// is about the encounter itself being retryable once the character
|
||||
// can fight again, not about regen math (covered by
|
||||
// CharacterVitalsService's own tests).
|
||||
dataSource.state.characters[0].currentHp = 1;
|
||||
dataSource.state.characters[0].hpRegenSince = null;
|
||||
|
||||
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(retry.id).not.toBe(combatId);
|
||||
@@ -787,7 +879,7 @@ describe('CombatService', () => {
|
||||
|
||||
it('keeps returning LOST after the combat has ended', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||
});
|
||||
const context = createService({ state });
|
||||
const started = await context.service.startCombat(
|
||||
@@ -844,6 +936,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -896,6 +991,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -956,6 +1054,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -993,6 +1094,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
fakeRewardService({
|
||||
// Genuinely write renown/silver through the transaction's manager
|
||||
// before failing, so the assertions below prove the rollback
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -16,6 +17,7 @@ import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatEngineState, CombatIntent } from './combat-engine.types';
|
||||
import {
|
||||
characterNotFound,
|
||||
characterTooWounded,
|
||||
characterTravelling,
|
||||
combatAlreadyActive,
|
||||
combatAlreadyFinished,
|
||||
@@ -78,6 +80,7 @@ export class CombatService {
|
||||
private readonly travelService: TravelService,
|
||||
private readonly combatEngine: CombatEngineService,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
private readonly combatRewards: CombatRewardService,
|
||||
) {}
|
||||
|
||||
@@ -138,6 +141,11 @@ export class CombatService {
|
||||
character,
|
||||
manager,
|
||||
);
|
||||
if (playerStats.currentHp < 1) {
|
||||
throw characterTooWounded();
|
||||
}
|
||||
this.characterVitals.pause(character, playerStats.currentHp);
|
||||
await characters.save(character);
|
||||
|
||||
const combat = combats.create({
|
||||
characterId,
|
||||
@@ -146,7 +154,7 @@ export class CombatService {
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
playerMaxHp: playerStats.maxHp,
|
||||
playerCurrentHp: playerStats.maxHp,
|
||||
playerCurrentHp: playerStats.currentHp,
|
||||
monsterMaxHp: monster.maxHp,
|
||||
monsterCurrentHp: monster.maxHp,
|
||||
playerState: {
|
||||
@@ -220,7 +228,7 @@ export class CombatService {
|
||||
// no-op re-lock — but locking it first here keeps both code paths
|
||||
// consistent and avoids a lock-order inversion that could deadlock two
|
||||
// concurrent requests against the same character. Do not reorder this.
|
||||
await this.lockCharacter(characters, characterId);
|
||||
const character = await this.lockCharacter(characters, characterId);
|
||||
|
||||
const combat = await combats.findOne({
|
||||
where: { id: combatId, characterId },
|
||||
@@ -251,12 +259,16 @@ export class CombatService {
|
||||
combat.monsterState = result.state.monster.stats;
|
||||
if (combat.status !== CombatStatus.ACTIVE) {
|
||||
combat.completedAt = new Date();
|
||||
this.characterVitals.resume(character, combat.playerCurrentHp);
|
||||
await this.settleEncounter(
|
||||
manager.getRepository(HuntEncounter),
|
||||
combat.huntEncounterId,
|
||||
combat.status,
|
||||
);
|
||||
} else {
|
||||
this.characterVitals.pause(character, combat.playerCurrentHp);
|
||||
}
|
||||
await characters.save(character);
|
||||
await combats.save(combat);
|
||||
|
||||
const startingSequence = await combatEvents.count({
|
||||
@@ -284,7 +296,7 @@ export class CombatService {
|
||||
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
||||
: null;
|
||||
|
||||
const [character, monster, events] = await Promise.all([
|
||||
const [reloadedCharacter, monster, events] = await Promise.all([
|
||||
this.loadCharacter(
|
||||
combat.characterId,
|
||||
manager.getRepository(Character),
|
||||
@@ -296,7 +308,7 @@ export class CombatService {
|
||||
this.loadEvents(combat.id, combatEvents),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||
return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHpRegeneration1792000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
|
||||
);
|
||||
|
||||
// Existing characters start regenerating immediately from their current
|
||||
// HP. A character whose fight is still ACTIVE keeps regeneration paused
|
||||
// until that fight resolves, matching the "no combat-time regen" rule
|
||||
// (persistent-hp-and-regeneration design, R4) -- this migration must not
|
||||
// gift them free healing mid-fight.
|
||||
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
|
||||
await queryRunner.query(`UPDATE "characters" AS "character"
|
||||
SET "hp_regen_since" = NULL
|
||||
FROM "combats" AS "combat"
|
||||
WHERE "combat"."character_id" = "character"."id"
|
||||
AND "combat"."status" = 'ACTIVE'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
|
||||
describe('characters.hp_regen_since schema', () => {
|
||||
it('stores the regeneration anchor as a nullable timestamptz', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const column = metadata.columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
|
||||
);
|
||||
|
||||
expect(column).toBeDefined();
|
||||
expect(column?.options.type).toBe('timestamptz');
|
||||
expect(column?.options.nullable).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,7 @@ export async function seedVisibleVerticalSlice(
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: new Date(),
|
||||
currentLocationId: southGateId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
@@ -191,6 +192,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
@@ -205,12 +207,17 @@ function createHarness(state: Partial<State> = {}) {
|
||||
...state,
|
||||
};
|
||||
const dataSource = new FakeDataSource(fullState);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const characterStats = new CharacterStatsService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterVitals,
|
||||
);
|
||||
const service = new EquipmentService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterStats,
|
||||
characterVitals,
|
||||
);
|
||||
return { state: fullState, service };
|
||||
}
|
||||
@@ -452,6 +459,38 @@ describe('EquipmentService', () => {
|
||||
'CHARACTER_IN_COMBAT',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
|
||||
const bonusHpHelm = itemDefinition({
|
||||
id: 'def-bonus-hp-helm',
|
||||
key: 'bonus-hp-helm',
|
||||
name: 'Gepolsterter Helm',
|
||||
equipmentSlot: EquipmentSlot.HEAD,
|
||||
bonusHp: 20,
|
||||
weaponDamage: 0,
|
||||
});
|
||||
const { state, service } = createHarness({
|
||||
characters: [
|
||||
character({
|
||||
currentHp: 90,
|
||||
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
|
||||
}),
|
||||
],
|
||||
itemDefinitions: [bonusHpHelm],
|
||||
characterItems: [
|
||||
{
|
||||
id: BANDIT_HOOD_ITEM_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: bonusHpHelm.id,
|
||||
quantity: 1,
|
||||
} as CharacterItem,
|
||||
],
|
||||
});
|
||||
|
||||
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
|
||||
|
||||
expect(state.characters[0].currentHp).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEquipment', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
@@ -50,6 +51,7 @@ export class EquipmentService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
) {}
|
||||
|
||||
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
|
||||
@@ -108,6 +110,10 @@ export class EquipmentService {
|
||||
throw itemNotEquippable();
|
||||
}
|
||||
|
||||
const statsBeforeChange = await this.characterStats.calculate(character, manager);
|
||||
this.characterVitals.settle(character, statsBeforeChange.maxHp);
|
||||
await characters.save(character);
|
||||
|
||||
const existing = await equipmentRepo.findOne({
|
||||
where: { characterId, slot: definition.equipmentSlot },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
|
||||
@@ -19,6 +19,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
|
||||
itemDefinition: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
rarity: ItemRarity.COMMON,
|
||||
type: ItemType.EQUIPMENT,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
@@ -66,6 +67,7 @@ describe('InventoryService', () => {
|
||||
item: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 8,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface InventoryItemDto {
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: ItemRarity;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
weaponDamage: number;
|
||||
@@ -55,6 +56,7 @@ export class InventoryService {
|
||||
item: {
|
||||
key: characterItem.itemDefinition.key,
|
||||
name: characterItem.itemDefinition.name,
|
||||
description: characterItem.itemDefinition.description,
|
||||
rarity: characterItem.itemDefinition.rarity,
|
||||
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
|
||||
weaponDamage: characterItem.itemDefinition.weaponDamage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { CLOCK, systemClock } from './clock';
|
||||
import { CLOCK, systemClock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import { TravelController } from './travel.controller';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../database/seeds/vertical-slice.constants';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { Clock } from './clock';
|
||||
import { Clock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import { TravelDomainError } from './travel.errors';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DataSource, Repository } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { CLOCK } from './clock';
|
||||
import type { Clock } from './clock';
|
||||
import { CLOCK } from '../shared/clock';
|
||||
import type { Clock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import {
|
||||
characterNotFound,
|
||||
|
||||
Reference in New Issue
Block a user