feat(web): count displayed HP up locally between server syncs

This commit is contained in:
Bastian Wagner
2026-08-21 16:06:54 +02:00
parent c27b6c5026
commit 848f9d9195
2 changed files with 112 additions and 3 deletions

View File

@@ -375,4 +375,71 @@ describe('WorldStore', () => {
expect(store.character()?.silver).toBe(0); expect(store.character()?.silver).toBe(0);
expect(store.error()).toBeNull(); 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);
});
});
}); });

View File

@@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class WorldStore implements OnDestroy { export class WorldStore implements OnDestroy {
private readonly characterState = signal<CharacterResponse | null>(null); private readonly characterState = signal<CharacterResponse | null>(null);
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
private readonly currentLocationState = signal<CurrentLocationResponse | null>(null); private readonly currentLocationState = signal<CurrentLocationResponse | null>(null);
private readonly selectedConnectionState = signal<CurrentLocationConnection | null>(null); private readonly selectedConnectionState = signal<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null); private readonly currentTravelState = signal<CurrentTravel | null>(null);
@@ -32,11 +33,13 @@ export class WorldStore implements OnDestroy {
private readonly loadingState = signal(false); private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null); private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | undefined; private countdownTimer: ReturnType<typeof setInterval> | undefined;
private regenTimer: ReturnType<typeof setInterval> | undefined;
private travelRetryTimer: ReturnType<typeof setTimeout> | undefined; private travelRetryTimer: ReturnType<typeof setTimeout> | undefined;
private travelPollInFlight = false; private travelPollInFlight = false;
private destroyed = false; private destroyed = false;
readonly character = this.characterState.asReadonly(); readonly character = this.characterState.asReadonly();
readonly displayedCharacter = this.displayedCharacterState.asReadonly();
readonly currentLocation = this.currentLocationState.asReadonly(); readonly currentLocation = this.currentLocationState.asReadonly();
readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly selectedConnection = this.selectedConnectionState.asReadonly();
readonly currentTravel = this.currentTravelState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly();
@@ -62,7 +65,7 @@ export class WorldStore implements OnDestroy {
return; return;
} }
this.characterState.set(character); this.applyCharacter(character);
this.currentLocationState.set(location); this.currentLocationState.set(location);
this.selectedConnectionState.set(null); this.selectedConnectionState.set(null);
await this.setCurrentTravel(travel); await this.setCurrentTravel(travel);
@@ -100,7 +103,7 @@ export class WorldStore implements OnDestroy {
try { try {
const character = await firstValueFrom(this.api.getCharacter()); const character = await firstValueFrom(this.api.getCharacter());
if (!this.destroyed) { if (!this.destroyed) {
this.characterState.set(character); this.applyCharacter(character);
} }
} catch { } catch {
// Keep the previous character; the next load() will resync. // Keep the previous character; the next load() will resync.
@@ -142,6 +145,7 @@ export class WorldStore implements OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed = true; this.destroyed = true;
this.stopCountdown(); this.stopCountdown();
this.stopRegenTicker();
this.clearTravelRetry(); this.clearTravelRetry();
} }
@@ -257,11 +261,49 @@ export class WorldStore implements OnDestroy {
return; return;
} }
this.characterState.set(character); this.applyCharacter(character);
this.currentLocationState.set(location); this.currentLocationState.set(location);
this.selectedConnectionState.set(null); 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 { private stopCountdown(): void {
if (this.countdownTimer !== undefined) { if (this.countdownTimer !== undefined) {
clearInterval(this.countdownTimer); clearInterval(this.countdownTimer);