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

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