This commit is contained in:
Bastian Wagner
2026-08-23 19:25:57 +02:00
parent bbd5dbbd1f
commit d0eecc45d6
8 changed files with 119 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -1,5 +1,5 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.displayedCharacter()" />
<app-top-bar [character]="topBarCharacter()" />
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
<app-side-navigation />

View File

@@ -0,0 +1,88 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import type { CharacterResponse, Combat } from '../../core/api/game-api.models';
import { CombatStore } from '../../features/combat/combat.store';
import { WorldStore } from '../../features/world/world.store';
import { AppShellComponent } from './app-shell.component';
function characterFixture(overrides: Partial<CharacterResponse> = {}): CharacterResponse {
return {
id: 'character-1',
name: 'Aric Duskwalker',
renown: 3,
silver: 120,
currentHp: 40,
maxHp: 100,
attack: 10,
hpRegenPerSecond: 1,
// Regenerating, as if the fight started mid-heal -- the world snapshot's
// own HP would keep climbing toward maxHp if the HUD trusted it.
hpRegenSince: new Date().toISOString(),
currentLocation: { id: 'location-1', key: 'aschenfelder', name: 'Ashen Fields' },
...overrides,
};
}
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 62,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'ash-rat',
name: 'Ash Rat',
level: 1,
maxHp: 45,
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [],
rewards: null,
};
@Component({ template: '' })
class BlankComponent {}
describe('AppShellComponent', () => {
async function setup(url: string, combat: Combat | null): Promise<HTMLElement> {
await TestBed.configureTestingModule({
imports: [AppShellComponent],
providers: [
provideRouter([{ path: '**', component: BlankComponent }]),
{ provide: WorldStore, useValue: { displayedCharacter: signal(characterFixture()) } },
{ provide: CombatStore, useValue: { combat: signal(combat) } },
],
}).compileComponents();
const router = TestBed.inject(Router);
await router.navigateByUrl(url);
const fixture = TestBed.createComponent(AppShellComponent);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
it('shows the live combat HP, not the regen-ticked world snapshot, while in a fight', async () => {
const element = await setup('/combat/combat-1', activeCombat);
expect(element.textContent).toContain('62 / 100');
expect(element.textContent).not.toContain('40 / 100');
});
it('shows the world snapshot HP outside of combat', async () => {
const element = await setup('/location', null);
expect(element.textContent).toContain('40 / 100');
});
});

View File

@@ -1,5 +1,7 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { Router, RouterOutlet, isActive } from '@angular/router';
import type { CharacterResponse } from '../../core/api/game-api.models';
import { CombatStore } from '../../features/combat/combat.store';
import { WorldStore } from '../../features/world/world.store';
import { ContextPanelComponent } from '../context-panel/context-panel.component';
import { GameFooterComponent } from '../game-footer/game-footer.component';
@@ -22,11 +24,30 @@ export class AppShellComponent {
private readonly router = inject(Router);
protected readonly worldStore = inject(WorldStore);
private readonly combatStore = inject(CombatStore);
// The fight has its own log rail and wants the width, and the area info
// belongs to the world view anyway, so the rail is dropped during combat.
private readonly inCombat = isActive('/combat', this.router);
// Outside a fight the HUD shows WorldStore's regen-ticked HP. Mid-fight,
// regen is paused server-side and the only HP that moves is the combat's --
// the world snapshot is stale, so it must not drive the bar.
protected readonly topBarCharacter = computed<CharacterResponse | null>(() => {
const character = this.worldStore.displayedCharacter();
const combat = this.inCombat() ? this.combatStore.combat() : null;
if (!character || !combat) {
return character;
}
return {
...character,
currentHp: combat.player.currentHp,
maxHp: combat.player.maxHp,
hpRegenSince: null,
};
});
// The location view brings its own, far richer context sidebar. Showing the
// shell's generic area panel next to it would say the same thing twice and
// squeeze the artwork the screen is built around.