From 848f9d9195839e5e87a0585c272ef6ffd3e7861b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 16:06:54 +0200 Subject: [PATCH] feat(web): count displayed HP up locally between server syncs --- .../app/features/world/world.store.spec.ts | 67 +++++++++++++++++++ .../web/src/app/features/world/world.store.ts | 48 ++++++++++++- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 6700f6d..a300b6a 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -375,4 +375,71 @@ describe('WorldStore', () => { expect(store.character()?.silver).toBe(0); expect(store.error()).toBeNull(); }); + + describe('HP regeneration display', () => { + it('counts displayedCharacter up once per second while an anchor is set', async () => { + const wounded: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(wounded)); + + await store.load(); + expect(store.displayedCharacter()?.currentHp).toBe(40); + + await vi.advanceTimersByTimeAsync(3_000); + + expect(store.displayedCharacter()?.currentHp).toBe(43); + }); + + it('stops at maxHp instead of counting past it', async () => { + const almostHealed: CharacterResponse = { + ...character, + currentHp: 99, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(almostHealed)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(100); + }); + + it('does not tick while regeneration is paused', async () => { + const paused: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: null, + }; + api.getCharacter.mockReturnValue(of(paused)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(40); + }); + + it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => { + api.getCharacter.mockReturnValue(of(character)); + await store.load(); + + const stillWounded: CharacterResponse = { + ...character, + currentHp: 10, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(stillWounded)); + await store.refreshCharacter(); + + await vi.advanceTimersByTimeAsync(4_000); + + expect(store.displayedCharacter()?.currentHp).toBe(14); + }); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 9f7ce4c..6a2cd23 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly> = { @Injectable({ providedIn: 'root' }) export class WorldStore implements OnDestroy { private readonly characterState = signal(null); + private readonly displayedCharacterState = signal(null); private readonly currentLocationState = signal(null); private readonly selectedConnectionState = signal(null); private readonly currentTravelState = signal(null); @@ -32,11 +33,13 @@ export class WorldStore implements OnDestroy { private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; + private regenTimer: ReturnType | undefined; private travelRetryTimer: ReturnType | undefined; private travelPollInFlight = false; private destroyed = false; readonly character = this.characterState.asReadonly(); + readonly displayedCharacter = this.displayedCharacterState.asReadonly(); readonly currentLocation = this.currentLocationState.asReadonly(); readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly(); @@ -62,7 +65,7 @@ export class WorldStore implements OnDestroy { return; } - this.characterState.set(character); + this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); await this.setCurrentTravel(travel); @@ -100,7 +103,7 @@ export class WorldStore implements OnDestroy { try { const character = await firstValueFrom(this.api.getCharacter()); if (!this.destroyed) { - this.characterState.set(character); + this.applyCharacter(character); } } catch { // Keep the previous character; the next load() will resync. @@ -142,6 +145,7 @@ export class WorldStore implements OnDestroy { ngOnDestroy(): void { this.destroyed = true; this.stopCountdown(); + this.stopRegenTicker(); this.clearTravelRetry(); } @@ -257,11 +261,49 @@ export class WorldStore implements OnDestroy { return; } - this.characterState.set(character); + this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); } + private applyCharacter(character: CharacterResponse): void { + this.characterState.set(character); + this.stopRegenTicker(); + this.refreshDisplayedCharacter(character); + if (character.hpRegenSince !== null) { + this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000); + } + } + + private refreshDisplayedCharacter(character: CharacterResponse): void { + if (this.destroyed) { + return; + } + + const currentHp = this.computeDisplayedHp(character); + this.displayedCharacterState.set({ ...character, currentHp }); + if (currentHp >= character.maxHp) { + this.stopRegenTicker(); + } + } + + private computeDisplayedHp(character: CharacterResponse): number { + if (character.hpRegenSince === null) { + return character.currentHp; + } + + const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000); + const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond); + return Math.min(character.maxHp, character.currentHp + regenerated); + } + + private stopRegenTicker(): void { + if (this.regenTimer !== undefined) { + clearInterval(this.regenTimer); + this.regenTimer = undefined; + } + } + private stopCountdown(): void { if (this.countdownTimer !== undefined) { clearInterval(this.countdownTimer);