feat(web): count displayed HP up locally between server syncs
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorldStore implements OnDestroy {
|
||||
private readonly characterState = signal<CharacterResponse | null>(null);
|
||||
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
|
||||
private readonly currentLocationState = signal<CurrentLocationResponse | null>(null);
|
||||
private readonly selectedConnectionState = signal<CurrentLocationConnection | 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 errorState = signal<string | null>(null);
|
||||
private countdownTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private regenTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private travelRetryTimer: ReturnType<typeof setTimeout> | 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);
|
||||
|
||||
Reference in New Issue
Block a user