feat: add signal-driven world state

This commit is contained in:
Bastian Wagner
2026-08-18 21:36:27 +02:00
parent c641168c7d
commit 610c404759
7 changed files with 487 additions and 4 deletions

View 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 };

View 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' });
});
});

View 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');
}
}