diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index e20fd29..d4f2062 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angul import { vi } from 'vitest'; import type { Combat } from '../../../core/api/game-api.models'; import { CombatStore } from '../combat.store'; +import { WorldStore } from '../../world/world.store'; import { CombatPageComponent } from './combat-page.component'; const activeCombat: Combat = { @@ -41,6 +42,7 @@ describe('CombatPageComponent', () => { loadCombat: ReturnType; attack: ReturnType; }; + let worldStore: { refreshCharacter: ReturnType }; let router: Router; async function setup(combat: Combat | null) { @@ -52,12 +54,14 @@ describe('CombatPageComponent', () => { loadCombat: vi.fn(() => Promise.resolve()), attack: vi.fn(() => Promise.resolve()), }; + worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; await TestBed.configureTestingModule({ imports: [CombatPageComponent], providers: [ provideRouter([]), { provide: CombatStore, useValue: combatStore }, + { provide: WorldStore, useValue: worldStore }, { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } }, @@ -190,6 +194,31 @@ describe('CombatPageComponent', () => { expect(element.textContent).toContain('0 / 45'); }); + it('refreshes the character from the server once a combat is won', async () => { + const fixture = await setup(activeCombat); + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set({ + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, + { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, + ], + }); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('[data-combat-attack]')?.click(); + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); + }); + it('disables Angriff while an action is pending', async () => { const fixture = await setup(activeCombat); combatStore.actionPending.set(true); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 51187a6..dcbd92c 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -8,6 +8,7 @@ import { runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; import { ItemCardComponent } from '../../../shared/item-card/item-card.component'; +import { WorldStore } from '../../world/world.store'; import { CombatStore } from '../combat.store'; interface CombatLogRound { @@ -34,6 +35,7 @@ const RIPOSTE_DELAY_MS = 260; }) export class CombatPageComponent implements OnInit { protected readonly combatStore = inject(CombatStore); + private readonly worldStore = inject(WorldStore); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -82,6 +84,12 @@ export class CombatPageComponent implements OnInit { return; } + if (after.status === 'WON') { + // The server already granted XP and silver; pull the authoritative + // character so the HUD matches (spec §35). + void this.worldStore.refreshCharacter(); + } + const riposte = after.events.find( (event) => event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER', diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 7e209ac..169defa 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -332,4 +332,26 @@ describe('WorldStore', () => { expect(api.getCurrentTravel).toHaveBeenCalledTimes(2); expect(store.currentTravel()).toEqual(travelling); }); + + it('refreshCharacter replaces the character from authoritative server data', async () => { + await store.load(); + + api.getCharacter.mockReturnValue(of({ ...character, experience: 32, silver: 18 })); + await store.refreshCharacter(); + + expect(store.character()?.experience).toBe(32); + expect(store.character()?.silver).toBe(18); + }); + + it('keeps the previous character when the refresh fails', async () => { + await store.load(); + + api.getCharacter.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + await store.refreshCharacter(); + + expect(store.character()?.silver).toBe(0); + expect(store.error()).toBeNull(); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 3f466e3..95d9c52 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -77,6 +77,27 @@ export class WorldStore implements OnDestroy { this.selectedConnectionState.set(connection); } + /** + * Re-reads the character from the server, e.g. after a combat granted XP and + * silver. Never mutates the values locally: the server owns them (spec §35). + * A failed refresh leaves the last known character in place rather than + * blanking the HUD. + */ + async refreshCharacter(): Promise { + if (this.destroyed) { + return; + } + + try { + const character = await firstValueFrom(this.api.getCharacter()); + if (!this.destroyed) { + this.characterState.set(character); + } + } catch { + // Keep the previous character; the next load() will resync. + } + } + async startTravel(): Promise { if (this.destroyed) { return; diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.html b/apps/web/src/app/layout/top-bar/top-bar.component.html index dde5327..eb34269 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.html +++ b/apps/web/src/app/layout/top-bar/top-bar.component.html @@ -15,6 +15,16 @@ > +
+
+
Silber
+
{{ character.silver }}
+
+
+
XP
+
{{ character.experience }}
+
+
} @else { Charakterdaten werden geladen } diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.scss b/apps/web/src/app/layout/top-bar/top-bar.component.scss index a979a7d..fd68c3e 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.scss +++ b/apps/web/src/app/layout/top-bar/top-bar.component.scss @@ -110,3 +110,29 @@ min-inline-size: 5rem; } } + +.top-bar__resources { + display: flex; + gap: var(--ar-space-4); + margin: 0; + padding-inline-start: var(--ar-space-4); + border-inline-start: 1px solid var(--ar-border); +} + +.top-bar__resource { + display: grid; + gap: var(--ar-space-1); +} + +.top-bar__resource dt { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.top-bar__resource dd { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; +}