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:
Bastian Wagner
2026-08-21 19:23:10 +02:00
75 changed files with 9250 additions and 1840 deletions

View File

@@ -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);
});
});

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1 @@
export const HP_REGEN_PER_SECOND = 1;

View 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);
});
});
});

View 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));
}
}

View File

@@ -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 {}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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;