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.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user