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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user