feat: add signal-driven world state
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
# Task 7 — Angular typed API and signal-driven WorldStore report
|
||||||
|
|
||||||
|
## Scope delivered
|
||||||
|
|
||||||
|
- Added typed public response models and `GameApiService` methods for character,
|
||||||
|
current location, travel start, and current travel.
|
||||||
|
- Configured Angular's application providers with `HttpClient`.
|
||||||
|
- Added `WorldStore` with private writable and public read-only signals for
|
||||||
|
character, world location, selection, travel, countdown, loading, and errors.
|
||||||
|
- The store derives presentation-only countdown seconds from server `arrivesAt`.
|
||||||
|
At zero it polls the API and never assigns the target location locally.
|
||||||
|
- Character and location are reloaded only after the API returns `COMPLETED`.
|
||||||
|
|
||||||
|
## Required preflight
|
||||||
|
|
||||||
|
- Re-read every document under `docs/`, including the approved design and
|
||||||
|
implementation plan, and inspected all three PNG reference images.
|
||||||
|
- Read the user-supplied visual asset guide. The untracked `apps/web/public/images/`
|
||||||
|
directory and `docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md`
|
||||||
|
remain unchanged and unstaged.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
### RED
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/web -- --watch=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial result: failed as expected because `game-api.service`,
|
||||||
|
`game-api.models`, and `world.store` did not exist. The compiler reported only
|
||||||
|
their unresolved imports from the newly added tests.
|
||||||
|
|
||||||
|
### GREEN
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/web -- --watch=false --include='src/app/core/api/game-api.service.spec.ts' --include='src/app/features/world/world.store.spec.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: 2 test files passed, 7 tests passed.
|
||||||
|
|
||||||
|
The store tests cover initial loading, target-only travel start, `arrivesAt`
|
||||||
|
countdown calculation, polling at zero without local arrival, and reload only
|
||||||
|
after `COMPLETED`.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/web -- --watch=false
|
||||||
|
# 3 test files passed, 9 tests passed
|
||||||
|
|
||||||
|
npm run build:web
|
||||||
|
# Angular production build completed successfully
|
||||||
|
|
||||||
|
npm exec --workspace=@ashen-realms/web -- prettier --check <Task-7 files>
|
||||||
|
# All matched files use Prettier code style
|
||||||
|
|
||||||
|
git diff --check -- <Task-7 files>
|
||||||
|
# exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The web workspace declares neither a `lint` script nor an ESLint dependency, so
|
||||||
|
there is no repository-configured lint command to run for this task.
|
||||||
|
|
||||||
|
## Deliberate limits and concerns
|
||||||
|
|
||||||
|
- The store retains a `COMPLETED` travel response after its authoritative
|
||||||
|
character/location refresh. The following API poll returns `IDLE`; the later
|
||||||
|
world UI can decide when to clear the completion presentation.
|
||||||
|
- The mandated code-review workflow normally requires a reviewer subagent, but
|
||||||
|
this task explicitly prohibited subagents. The implementation was instead
|
||||||
|
reviewed directly against the task brief and verified through the focused and
|
||||||
|
complete web test/build checks above.
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideBrowserGlobalErrorListeners(), provideHttpClient(), provideRouter(routes)],
|
||||||
provideBrowserGlobalErrorListeners(),
|
|
||||||
provideRouter(routes)
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|||||||
48
apps/web/src/app/core/api/game-api.models.ts
Normal file
48
apps/web/src/app/core/api/game-api.models.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export interface LocationSummary {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
experience: number;
|
||||||
|
currentHp: number;
|
||||||
|
maxHp: number;
|
||||||
|
attack: number;
|
||||||
|
currentLocation: LocationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentLocationConnection {
|
||||||
|
targetLocation: LocationSummary;
|
||||||
|
travelDurationSeconds: number;
|
||||||
|
danger: 'LOW' | 'HIGH';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentLocationResponse {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
regionKey: string;
|
||||||
|
minRecommendedLevel: number;
|
||||||
|
maxRecommendedLevel: number;
|
||||||
|
dangerLevel: number;
|
||||||
|
isSafe: boolean;
|
||||||
|
huntingEnabled: boolean;
|
||||||
|
artworkPath: string;
|
||||||
|
connections: CurrentLocationConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrentTravel =
|
||||||
|
| { status: 'IDLE' }
|
||||||
|
| {
|
||||||
|
status: 'TRAVELLING';
|
||||||
|
originLocation: LocationSummary;
|
||||||
|
targetLocation: LocationSummary;
|
||||||
|
startedAt: string;
|
||||||
|
arrivesAt: string;
|
||||||
|
}
|
||||||
|
| { status: 'COMPLETED'; targetLocation: LocationSummary };
|
||||||
50
apps/web/src/app/core/api/game-api.service.spec.ts
Normal file
50
apps/web/src/app/core/api/game-api.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { GameApiService } from './game-api.service';
|
||||||
|
|
||||||
|
describe('GameApiService', () => {
|
||||||
|
let service: GameApiService;
|
||||||
|
let http: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [GameApiService, provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(GameApiService);
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
http.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses relative API URLs for all read requests', () => {
|
||||||
|
service.getCharacter().subscribe();
|
||||||
|
service.getCurrentLocation().subscribe();
|
||||||
|
service.getCurrentTravel().subscribe();
|
||||||
|
|
||||||
|
const requests = http.match((request) => request.method === 'GET');
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.request.url)).toEqual([
|
||||||
|
'/api/characters/me',
|
||||||
|
'/api/world/current-location',
|
||||||
|
'/api/travel/current',
|
||||||
|
]);
|
||||||
|
expect(requests.every((request) => !request.request.url.includes('://'))).toBe(true);
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
request.flush({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts only the target location ID when starting travel', () => {
|
||||||
|
service.startTravel('target-uuid').subscribe();
|
||||||
|
|
||||||
|
const request = http.expectOne('/api/travel');
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({ targetLocationId: 'target-uuid' });
|
||||||
|
request.flush({ status: 'IDLE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/web/src/app/core/api/game-api.service.ts
Normal file
25
apps/web/src/app/core/api/game-api.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { CharacterResponse, CurrentLocationResponse, CurrentTravel } from './game-api.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GameApiService {
|
||||||
|
constructor(private readonly http: HttpClient) {}
|
||||||
|
|
||||||
|
getCharacter(): Observable<CharacterResponse> {
|
||||||
|
return this.http.get<CharacterResponse>('/api/characters/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentLocation(): Observable<CurrentLocationResponse> {
|
||||||
|
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
|
||||||
|
}
|
||||||
|
|
||||||
|
startTravel(targetLocationId: string): Observable<CurrentTravel> {
|
||||||
|
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTravel(): Observable<CurrentTravel> {
|
||||||
|
return this.http.get<CurrentTravel>('/api/travel/current');
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apps/web/src/app/features/world/world.store.spec.ts
Normal file
137
apps/web/src/app/features/world/world.store.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
CharacterResponse,
|
||||||
|
CurrentLocationResponse,
|
||||||
|
CurrentTravel,
|
||||||
|
} from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
import { WorldStore } from './world.store';
|
||||||
|
|
||||||
|
const character: CharacterResponse = {
|
||||||
|
id: 'character-id',
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
currentHp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
attack: 6,
|
||||||
|
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLocation: CurrentLocationResponse = {
|
||||||
|
id: 'origin-id',
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Südtor',
|
||||||
|
description: 'Der Ausgang zur Wildnis.',
|
||||||
|
regionKey: 'ashen-fields',
|
||||||
|
minRecommendedLevel: 1,
|
||||||
|
maxRecommendedLevel: 1,
|
||||||
|
dangerLevel: 1,
|
||||||
|
isSafe: true,
|
||||||
|
huntingEnabled: false,
|
||||||
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
targetLocation: {
|
||||||
|
id: 'target-id',
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Straße',
|
||||||
|
},
|
||||||
|
travelDurationSeconds: 10,
|
||||||
|
danger: 'LOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const travelling: CurrentTravel = {
|
||||||
|
status: 'TRAVELLING',
|
||||||
|
originLocation: character.currentLocation,
|
||||||
|
targetLocation: currentLocation.connections[0].targetLocation,
|
||||||
|
startedAt: '2026-08-18T10:00:00.000Z',
|
||||||
|
arrivesAt: '2026-08-18T10:00:10.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WorldStore', () => {
|
||||||
|
let api: {
|
||||||
|
getCharacter: ReturnType<typeof vi.fn>;
|
||||||
|
getCurrentLocation: ReturnType<typeof vi.fn>;
|
||||||
|
getCurrentTravel: ReturnType<typeof vi.fn>;
|
||||||
|
startTravel: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let store: WorldStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-18T10:00:00.000Z'));
|
||||||
|
api = {
|
||||||
|
getCharacter: vi.fn(() => of(character)),
|
||||||
|
getCurrentLocation: vi.fn(() => of(currentLocation)),
|
||||||
|
getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)),
|
||||||
|
startTravel: vi.fn(() => of(travelling)),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [WorldStore, { provide: GameApiService, useValue: api }],
|
||||||
|
});
|
||||||
|
store = TestBed.inject(WorldStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the character, current location, and current travel', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
expect(api.getCharacter).toHaveBeenCalledOnce();
|
||||||
|
expect(api.getCurrentLocation).toHaveBeenCalledOnce();
|
||||||
|
expect(api.getCurrentTravel).toHaveBeenCalledOnce();
|
||||||
|
expect(store.character()).toEqual(character);
|
||||||
|
expect(store.currentLocation()).toEqual(currentLocation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts travel with only the selected target ID', async () => {
|
||||||
|
await store.load();
|
||||||
|
store.selectConnection(currentLocation.connections[0]);
|
||||||
|
|
||||||
|
await store.startTravel();
|
||||||
|
|
||||||
|
expect(api.startTravel).toHaveBeenCalledWith('target-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the remaining seconds from the server arrivesAt timestamp', async () => {
|
||||||
|
api.getCurrentTravel.mockReturnValue(of(travelling));
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
expect(store.remainingSeconds()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls the server at zero without assigning the target as the current location', async () => {
|
||||||
|
api.getCurrentTravel
|
||||||
|
.mockReturnValueOnce(of(travelling))
|
||||||
|
.mockReturnValueOnce(of({ status: 'IDLE' } satisfies CurrentTravel));
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
const initialLocation = store.currentLocation();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
|
||||||
|
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.currentLocation()).toBe(initialLocation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads character and location only after the server reports completion', async () => {
|
||||||
|
api.getCurrentTravel
|
||||||
|
.mockReturnValueOnce(of(travelling))
|
||||||
|
.mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation }));
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
|
||||||
|
expect(api.getCharacter).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
152
apps/web/src/app/features/world/world.store.ts
Normal file
152
apps/web/src/app/features/world/world.store.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom, forkJoin } from 'rxjs';
|
||||||
|
import {
|
||||||
|
CharacterResponse,
|
||||||
|
CurrentLocationConnection,
|
||||||
|
CurrentLocationResponse,
|
||||||
|
CurrentTravel,
|
||||||
|
} from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class WorldStore implements OnDestroy {
|
||||||
|
private readonly characterState = 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);
|
||||||
|
private readonly remainingSecondsState = signal<number | null>(null);
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private readonly errorState = signal<string | null>(null);
|
||||||
|
private countdownTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
readonly character = this.characterState.asReadonly();
|
||||||
|
readonly currentLocation = this.currentLocationState.asReadonly();
|
||||||
|
readonly selectedConnection = this.selectedConnectionState.asReadonly();
|
||||||
|
readonly currentTravel = this.currentTravelState.asReadonly();
|
||||||
|
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
|
||||||
|
readonly loading = this.loadingState.asReadonly();
|
||||||
|
readonly error = this.errorState.asReadonly();
|
||||||
|
|
||||||
|
constructor(private readonly api: GameApiService) {}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loadingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const travel = await this.loadSnapshot();
|
||||||
|
await this.setCurrentTravel(travel);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectConnection(connection: CurrentLocationConnection | null): void {
|
||||||
|
this.selectedConnectionState.set(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTravel(): Promise<void> {
|
||||||
|
const connection = this.selectedConnectionState();
|
||||||
|
if (!connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const travel = await firstValueFrom(this.api.startTravel(connection.targetLocation.id));
|
||||||
|
await this.setCurrentTravel(travel);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSnapshot(): Promise<CurrentTravel> {
|
||||||
|
const { character, location, travel } = await firstValueFrom(
|
||||||
|
forkJoin({
|
||||||
|
character: this.api.getCharacter(),
|
||||||
|
location: this.api.getCurrentLocation(),
|
||||||
|
travel: this.api.getCurrentTravel(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.characterState.set(character);
|
||||||
|
this.currentLocationState.set(location);
|
||||||
|
this.selectedConnectionState.set(null);
|
||||||
|
return travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setCurrentTravel(travel: CurrentTravel): Promise<void> {
|
||||||
|
this.currentTravelState.set(travel);
|
||||||
|
this.stopCountdown();
|
||||||
|
|
||||||
|
if (travel.status === 'TRAVELLING') {
|
||||||
|
this.startCountdown(travel.arrivesAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.remainingSecondsState.set(null);
|
||||||
|
if (travel.status === 'COMPLETED') {
|
||||||
|
await this.reloadAuthoritativeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startCountdown(arrivesAt: string): void {
|
||||||
|
const updateRemainingSeconds = () => {
|
||||||
|
const remainingSeconds = Math.max(0, Math.ceil((Date.parse(arrivesAt) - Date.now()) / 1000));
|
||||||
|
this.remainingSecondsState.set(remainingSeconds);
|
||||||
|
|
||||||
|
if (remainingSeconds === 0) {
|
||||||
|
this.stopCountdown();
|
||||||
|
void this.refreshTravelAfterCountdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateRemainingSeconds();
|
||||||
|
if (this.countdownTimer === undefined) {
|
||||||
|
this.countdownTimer = setInterval(updateRemainingSeconds, 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshTravelAfterCountdown(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const travel = await firstValueFrom(this.api.getCurrentTravel());
|
||||||
|
await this.setCurrentTravel(travel);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reloadAuthoritativeState(): Promise<void> {
|
||||||
|
const { character, location } = await firstValueFrom(
|
||||||
|
forkJoin({
|
||||||
|
character: this.api.getCharacter(),
|
||||||
|
location: this.api.getCurrentLocation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.characterState.set(character);
|
||||||
|
this.currentLocationState.set(location);
|
||||||
|
this.selectedConnectionState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopCountdown(): void {
|
||||||
|
if (this.countdownTimer !== undefined) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Unable to load world state.';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user