feat(web): show authoritative silver and XP in the top bar after victory
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angul
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import type { Combat } from '../../../core/api/game-api.models';
|
import type { Combat } from '../../../core/api/game-api.models';
|
||||||
import { CombatStore } from '../combat.store';
|
import { CombatStore } from '../combat.store';
|
||||||
|
import { WorldStore } from '../../world/world.store';
|
||||||
import { CombatPageComponent } from './combat-page.component';
|
import { CombatPageComponent } from './combat-page.component';
|
||||||
|
|
||||||
const activeCombat: Combat = {
|
const activeCombat: Combat = {
|
||||||
@@ -41,6 +42,7 @@ describe('CombatPageComponent', () => {
|
|||||||
loadCombat: ReturnType<typeof vi.fn>;
|
loadCombat: ReturnType<typeof vi.fn>;
|
||||||
attack: ReturnType<typeof vi.fn>;
|
attack: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
async function setup(combat: Combat | null) {
|
async function setup(combat: Combat | null) {
|
||||||
@@ -52,12 +54,14 @@ describe('CombatPageComponent', () => {
|
|||||||
loadCombat: vi.fn(() => Promise.resolve()),
|
loadCombat: vi.fn(() => Promise.resolve()),
|
||||||
attack: vi.fn(() => Promise.resolve()),
|
attack: vi.fn(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
|
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CombatPageComponent],
|
imports: [CombatPageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: CombatStore, useValue: combatStore },
|
{ provide: CombatStore, useValue: combatStore },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
|
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
|
||||||
@@ -190,6 +194,31 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(element.textContent).toContain('0 / 45');
|
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<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||||
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('disables Angriff while an action is pending', async () => {
|
it('disables Angriff while an action is pending', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
combatStore.actionPending.set(true);
|
combatStore.actionPending.set(true);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
runtimeMonsterArtworkPath,
|
runtimeMonsterArtworkPath,
|
||||||
} from '../../../shared/monster-artwork';
|
} from '../../../shared/monster-artwork';
|
||||||
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
|
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
|
||||||
|
import { WorldStore } from '../../world/world.store';
|
||||||
import { CombatStore } from '../combat.store';
|
import { CombatStore } from '../combat.store';
|
||||||
|
|
||||||
interface CombatLogRound {
|
interface CombatLogRound {
|
||||||
@@ -34,6 +35,7 @@ const RIPOSTE_DELAY_MS = 260;
|
|||||||
})
|
})
|
||||||
export class CombatPageComponent implements OnInit {
|
export class CombatPageComponent implements OnInit {
|
||||||
protected readonly combatStore = inject(CombatStore);
|
protected readonly combatStore = inject(CombatStore);
|
||||||
|
private readonly worldStore = inject(WorldStore);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -82,6 +84,12 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return;
|
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(
|
const riposte = after.events.find(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
|
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
|
||||||
|
|||||||
@@ -332,4 +332,26 @@ describe('WorldStore', () => {
|
|||||||
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
|
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
|
||||||
expect(store.currentTravel()).toEqual(travelling);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,27 @@ export class WorldStore implements OnDestroy {
|
|||||||
this.selectedConnectionState.set(connection);
|
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<void> {
|
||||||
|
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<void> {
|
async startTravel(): Promise<void> {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="top-bar__resources">
|
||||||
|
<div class="top-bar__resource" data-top-bar-silver>
|
||||||
|
<dt>Silber</dt>
|
||||||
|
<dd>{{ character.silver }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="top-bar__resource" data-top-bar-experience>
|
||||||
|
<dt>XP</dt>
|
||||||
|
<dd>{{ character.experience }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="top-bar__loading">Charakterdaten werden geladen</span>
|
<span class="top-bar__loading">Charakterdaten werden geladen</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,3 +110,29 @@
|
|||||||
min-inline-size: 5rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user