Merge branch 'master' into worktree-encounter-status
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 404 KiB |
BIN
apps/web/public/images/items/ash-blade.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/web/public/images/items/ash-boots.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
apps/web/public/images/items/ash-pelt.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
apps/web/public/images/items/bandit-blade.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
apps/web/public/images/items/bandit-hood.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/public/images/items/borderwatch-sigil.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/web/public/images/items/burned-captain-pendant.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
apps/web/public/images/items/guardsman-legs.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
apps/web/public/images/items/raider-gloves.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
apps/web/public/images/items/reinforced-leather-jacket.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
apps/web/public/images/items/small-healing-potion.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
apps/web/public/images/items/worn-short-sword.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
@@ -86,6 +86,7 @@ describe('App', () => {
|
||||
name: 'Mara Ashfall',
|
||||
level: 7,
|
||||
experience: 320,
|
||||
silver: 150,
|
||||
currentHp: 52,
|
||||
maxHp: 80,
|
||||
attack: 12,
|
||||
@@ -99,6 +100,8 @@ describe('App', () => {
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7');
|
||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80');
|
||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150');
|
||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
|
||||
});
|
||||
|
||||
it('redirects root and unknown routes to the world shell', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface CharacterResponse {
|
||||
name: string;
|
||||
level: number;
|
||||
experience: number;
|
||||
silver: number;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
attack: number;
|
||||
@@ -108,4 +109,26 @@ export interface Combat {
|
||||
player: CombatPlayer;
|
||||
monster: CombatMonster;
|
||||
events: CombatEvent[];
|
||||
rewards: CombatReward | null;
|
||||
}
|
||||
|
||||
export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC';
|
||||
|
||||
export interface RewardItemSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
rarity: ItemRarity;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export interface CombatRewardItem {
|
||||
characterItemId: string;
|
||||
item: RewardItemSummary;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CombatReward {
|
||||
experience: number;
|
||||
silver: number;
|
||||
items: CombatRewardItem[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="combat" aria-label="Kampf">
|
||||
@if (combat(); as combat) {
|
||||
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
|
||||
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
|
||||
<header class="combat__status">
|
||||
<div class="fighter fighter--player">
|
||||
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
||||
@@ -86,7 +86,37 @@
|
||||
<div class="outcome outcome--won" data-combat-result="WON">
|
||||
<h2 class="outcome__title">Sieg</h2>
|
||||
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
||||
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
|
||||
|
||||
@if (combat.rewards; as rewards) {
|
||||
<section class="rewards" data-combat-rewards aria-label="Belohnungen">
|
||||
<h3 class="rewards__title">Belohnungen</h3>
|
||||
|
||||
<dl class="rewards__currencies">
|
||||
<div class="rewards__currency" data-reward-experience>
|
||||
<dt>Erfahrung</dt>
|
||||
<dd>+{{ rewards.experience }} XP</dd>
|
||||
</div>
|
||||
<div class="rewards__currency" data-reward-silver>
|
||||
<dt>Silber</dt>
|
||||
<dd>+{{ rewards.silver }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (rewards.items.length) {
|
||||
<h3 class="rewards__title">Beute</h3>
|
||||
<ul class="rewards__loot">
|
||||
@for (reward of rewards.items; track reward.characterItemId) {
|
||||
<li>
|
||||
<app-item-card [item]="reward.item" [quantity]="reward.quantity" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="outcome__hint" data-reward-empty>Keine besondere Beute gefunden.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||
Zur Jagd
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
container-type: inline-size;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: var(--ar-space-4);
|
||||
min-block-size: 26rem;
|
||||
// The sprites are sized as a share of the stage, so the stage has to carry a
|
||||
// height of its own. With only a min-block-size the tallest cut-out -- the
|
||||
// road bandit -- sized the battlefield row from its intrinsic height and
|
||||
// pushed the page into a scrollbar. The subtracted 12.5rem is the shell
|
||||
// chrome around the main column: top bar, footer and its own padding.
|
||||
block-size: max(24rem, calc(100dvh - 12.5rem));
|
||||
padding: var(--ar-space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ar-border);
|
||||
@@ -182,7 +187,11 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
// The single row is spelled out rather than left implicit: only then is it a
|
||||
// definite height, and only then do the sprites' percentage heights resolve
|
||||
// against the battlefield instead of against their own artwork.
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-items: end;
|
||||
min-block-size: 0;
|
||||
}
|
||||
@@ -448,6 +457,10 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.outcome--won {
|
||||
inline-size: min(32rem, 90%);
|
||||
}
|
||||
|
||||
.outcome--won .outcome__title {
|
||||
color: var(--ar-gold);
|
||||
}
|
||||
@@ -602,7 +615,7 @@
|
||||
}
|
||||
|
||||
.combat__stage {
|
||||
min-block-size: clamp(24rem, 55vh, 34rem);
|
||||
block-size: clamp(20rem, 55dvh, 34rem);
|
||||
}
|
||||
|
||||
.combat__log {
|
||||
@@ -641,3 +654,61 @@
|
||||
max-inline-size: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reward summary: dark stone and bronze, large readable values, restrained
|
||||
rarity emphasis. No confetti, no popups, no slot-machine reveal (spec §50). */
|
||||
.rewards {
|
||||
display: grid;
|
||||
gap: var(--ar-space-3);
|
||||
justify-items: center;
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--ar-space-3);
|
||||
padding-block-start: var(--ar-space-3);
|
||||
border-block-start: 1px solid var(--ar-border);
|
||||
}
|
||||
|
||||
.rewards__title {
|
||||
margin: 0;
|
||||
color: var(--ar-text-muted);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--ar-font-sm);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rewards__currencies {
|
||||
display: flex;
|
||||
gap: var(--ar-space-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rewards__currency {
|
||||
display: grid;
|
||||
gap: var(--ar-space-1);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.rewards__currency dt {
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rewards__currency dd {
|
||||
margin: 0;
|
||||
color: var(--ar-gold);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
|
||||
}
|
||||
|
||||
.rewards__loot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ar-space-4);
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -23,6 +24,7 @@ const activeCombat: Combat = {
|
||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||
],
|
||||
rewards: null,
|
||||
};
|
||||
|
||||
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
||||
@@ -40,6 +42,7 @@ describe('CombatPageComponent', () => {
|
||||
loadCombat: ReturnType<typeof vi.fn>;
|
||||
attack: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||
let router: Router;
|
||||
|
||||
async function setup(combat: Combat | null) {
|
||||
@@ -51,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' }) } },
|
||||
@@ -162,7 +167,8 @@ describe('CombatPageComponent', () => {
|
||||
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
||||
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
|
||||
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
|
||||
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
|
||||
// The stage jolt is wired up but no longer fires on an ordinary hit.
|
||||
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
||||
expect(element.textContent).toContain('90 / 100');
|
||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
||||
|
||||
@@ -208,6 +214,31 @@ describe('CombatPageComponent', () => {
|
||||
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
combatStore.actionPending.set(true);
|
||||
@@ -257,4 +288,85 @@ describe('CombatPageComponent', () => {
|
||||
|
||||
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const wonWithRewards: Combat = {
|
||||
...activeCombat,
|
||||
status: 'WON',
|
||||
monster: { ...activeCombat.monster, currentHp: 0 },
|
||||
rewards: { experience: 8, silver: 6, items: [] },
|
||||
};
|
||||
|
||||
it('shows the granted XP and silver on the victory screen', async () => {
|
||||
const fixture = await setup(wonWithRewards);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
|
||||
expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('8');
|
||||
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6');
|
||||
});
|
||||
|
||||
it('renders a dropped item with its icon, name, and rarity', async () => {
|
||||
const fixture = await setup({
|
||||
...wonWithRewards,
|
||||
monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Straßenräuber', currentHp: 0 },
|
||||
rewards: {
|
||||
experience: 16,
|
||||
silver: 12,
|
||||
items: [
|
||||
{
|
||||
characterItemId: 'character-item-1',
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
rarity: 'COMMON',
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe(
|
||||
'/images/items/bandit-blade.png',
|
||||
);
|
||||
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
|
||||
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
|
||||
expect(element.querySelector('[data-reward-empty]')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a victory without loot as complete, not as a failure', async () => {
|
||||
const fixture = await setup(wonWithRewards);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('[data-reward-empty]')?.textContent).toContain(
|
||||
'Keine besondere Beute gefunden.',
|
||||
);
|
||||
expect(element.querySelector('[role="alert"]')).toBeNull();
|
||||
// XP and silver still carry the screen.
|
||||
expect(element.querySelector('[data-reward-experience]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the persisted rewards of an already-won combat loaded from the server', async () => {
|
||||
// Simulates a browser refresh: the page loads the combat by id and shows
|
||||
// exactly what the server persisted, without rerolling anything.
|
||||
const fixture = await setup({
|
||||
...wonWithRewards,
|
||||
rewards: { experience: 16, silver: 12, items: [] },
|
||||
});
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
|
||||
expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16');
|
||||
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12');
|
||||
});
|
||||
|
||||
it('still shows a plain victory when the server reports no reward record', async () => {
|
||||
const fixture = await setup({ ...wonWithRewards, rewards: null });
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
||||
expect(element.querySelector('[data-combat-rewards]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
monsterIconPath,
|
||||
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 {
|
||||
@@ -28,14 +30,18 @@ const SWING_MS = 540;
|
||||
const RECOIL_MS = 540;
|
||||
// Beat between the player's blow landing and the monster striking back.
|
||||
const RIPOSTE_DELAY_MS = 260;
|
||||
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
|
||||
const STAGE_SHAKE_MS = 200;
|
||||
|
||||
@Component({
|
||||
selector: 'app-combat-page',
|
||||
templateUrl: './combat-page.component.html',
|
||||
styleUrl: './combat-page.component.scss',
|
||||
imports: [ItemCardComponent],
|
||||
})
|
||||
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);
|
||||
@@ -47,9 +53,15 @@ export class CombatPageComponent implements OnInit {
|
||||
private readonly displayed = signal<Combat | null>(null);
|
||||
private readonly replaying = signal(false);
|
||||
|
||||
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
|
||||
// it was too much for every single round. Call `shakeStage()` to bring it
|
||||
// back for a specific ability.
|
||||
private readonly stageShaking = signal(false);
|
||||
|
||||
protected readonly combat = this.displayed.asReadonly();
|
||||
protected readonly phase = signal<CombatPhase>('idle');
|
||||
protected readonly monsterPhase = signal<MonsterPhase>('idle');
|
||||
protected readonly stageShake = this.stageShaking.asReadonly();
|
||||
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
|
||||
protected readonly playerIcon = PLAYER_ICON;
|
||||
|
||||
@@ -87,6 +99,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',
|
||||
@@ -125,6 +143,16 @@ export class CombatPageComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
|
||||
protected shakeStage(): void {
|
||||
this.stageShaking.set(true);
|
||||
setTimeout(() => {
|
||||
if (!this.destroyed) {
|
||||
this.stageShaking.set(false);
|
||||
}
|
||||
}, STAGE_SHAKE_MS);
|
||||
}
|
||||
|
||||
protected retry(): void {
|
||||
void this.loadFromRoute();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const startedCombat: Combat = {
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
},
|
||||
events: [],
|
||||
rewards: null,
|
||||
};
|
||||
|
||||
const afterAttack: Combat = {
|
||||
|
||||
@@ -82,6 +82,7 @@ const startedCombat: Combat = {
|
||||
artworkPath: '/images/enemies/RoadBandit.png',
|
||||
},
|
||||
events: [],
|
||||
rewards: null,
|
||||
};
|
||||
|
||||
describe('HuntPageComponent', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const character: CharacterResponse = {
|
||||
name: 'Aric Duskwalker',
|
||||
level: 1,
|
||||
experience: 0,
|
||||
silver: 0,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
@@ -331,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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> {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<div class="app-shell">
|
||||
<app-top-bar [character]="worldStore.character()" />
|
||||
|
||||
<div class="app-shell__content">
|
||||
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
|
||||
<app-side-navigation />
|
||||
<main class="app-shell__main" aria-label="Spielinhalt">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-context-panel />
|
||||
@if (!inCombat()) {
|
||||
<app-context-panel />
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-game-footer />
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
// Without the context rail the main column takes its place. The narrow layouts
|
||||
// below re-declare the template, so they keep working either way.
|
||||
.app-shell__content--no-context {
|
||||
grid-template-columns: minmax(11rem, 13rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
min-inline-size: 0;
|
||||
min-block-size: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { Router, RouterOutlet, isActive } from '@angular/router';
|
||||
import { WorldStore } from '../../features/world/world.store';
|
||||
import { ContextPanelComponent } from '../context-panel/context-panel.component';
|
||||
import { GameFooterComponent } from '../game-footer/game-footer.component';
|
||||
@@ -20,4 +20,8 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
|
||||
})
|
||||
export class AppShellComponent {
|
||||
protected readonly worldStore = inject(WorldStore);
|
||||
|
||||
// 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.
|
||||
protected readonly inCombat = isActive('/combat', inject(Router));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
></span>
|
||||
</span>
|
||||
</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 {
|
||||
<span class="top-bar__loading">Charakterdaten werden geladen</span>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
10
apps/web/src/app/shared/item-card/item-card.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<article class="item-card" [class]="rarityModifier()">
|
||||
<div class="item-card__frame">
|
||||
<img class="item-card__icon" data-item-icon [src]="item().iconPath" [alt]="item().name" />
|
||||
@if (quantity() > 1) {
|
||||
<span class="item-card__quantity" data-item-quantity>×{{ quantity() }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="item-card__name" data-item-name>{{ item().name }}</p>
|
||||
<p class="item-card__rarity" data-item-rarity>{{ rarityLabel() }}</p>
|
||||
</article>
|
||||
66
apps/web/src/app/shared/item-card/item-card.component.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
.item-card {
|
||||
display: grid;
|
||||
gap: var(--ar-space-1);
|
||||
justify-items: center;
|
||||
inline-size: 8.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-card__frame {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 6rem;
|
||||
block-size: 6rem;
|
||||
padding: var(--ar-space-1);
|
||||
border: 1px solid var(--ar-border);
|
||||
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||
box-shadow: var(--ar-shadow-raised);
|
||||
}
|
||||
|
||||
.item-card__icon {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.item-card__quantity {
|
||||
position: absolute;
|
||||
inset-block-end: 0.1rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
color: var(--ar-text);
|
||||
font-size: var(--ar-font-sm);
|
||||
text-shadow: 0 0 0.3rem #000;
|
||||
}
|
||||
|
||||
.item-card__name {
|
||||
margin: 0;
|
||||
color: var(--ar-text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.item-card__rarity {
|
||||
margin: 0;
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Restrained rarity emphasis: the frame edge carries it, nothing flashes. */
|
||||
.item-card--rare .item-card__frame {
|
||||
border-color: var(--ar-blue);
|
||||
}
|
||||
|
||||
.item-card--rare .item-card__rarity {
|
||||
color: var(--ar-blue);
|
||||
}
|
||||
|
||||
.item-card--epic .item-card__frame {
|
||||
border-color: var(--ar-border-highlight);
|
||||
}
|
||||
|
||||
.item-card--epic .item-card__rarity {
|
||||
color: var(--ar-gold);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { RewardItemSummary } from '../../core/api/game-api.models';
|
||||
import { ItemCardComponent } from './item-card.component';
|
||||
|
||||
const banditBlade: RewardItemSummary = {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
rarity: 'COMMON',
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
};
|
||||
|
||||
async function setup(item: RewardItemSummary, quantity = 1) {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({ imports: [ItemCardComponent] }).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ItemCardComponent);
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('quantity', quantity);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe('ItemCardComponent', () => {
|
||||
it('renders the icon, name, and German rarity label', async () => {
|
||||
const fixture = await setup(banditBlade);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const icon = element.querySelector<HTMLImageElement>('[data-item-icon]');
|
||||
expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png');
|
||||
expect(icon?.getAttribute('alt')).toBe('Räuberklinge');
|
||||
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
|
||||
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
|
||||
});
|
||||
|
||||
it('labels the other rarities in German too', async () => {
|
||||
const rare = await setup({ ...banditBlade, rarity: 'RARE' });
|
||||
expect(
|
||||
(rare.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||
).toContain('Selten');
|
||||
|
||||
const epic = await setup({ ...banditBlade, rarity: 'EPIC' });
|
||||
expect(
|
||||
(epic.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||
).toContain('Episch');
|
||||
});
|
||||
|
||||
it('hides the quantity for a single item and shows it for a stack', async () => {
|
||||
const single = await setup(banditBlade, 1);
|
||||
expect((single.nativeElement as HTMLElement).querySelector('[data-item-quantity]')).toBeNull();
|
||||
|
||||
const stack = await setup(banditBlade, 3);
|
||||
expect(
|
||||
(stack.nativeElement as HTMLElement).querySelector('[data-item-quantity]')?.textContent,
|
||||
).toContain('3');
|
||||
});
|
||||
|
||||
it('offers no equip or comparison affordance yet', async () => {
|
||||
const fixture = await setup(banditBlade);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('button')).toBeNull();
|
||||
expect(element.textContent).not.toContain('Anlegen');
|
||||
});
|
||||
});
|
||||
27
apps/web/src/app/shared/item-card/item-card.component.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import type { ItemRarity, RewardItemSummary } from '../../core/api/game-api.models';
|
||||
|
||||
export const RARITY_LABELS: Readonly<Record<ItemRarity, string>> = {
|
||||
COMMON: 'Gewöhnlich',
|
||||
RARE: 'Selten',
|
||||
EPIC: 'Episch',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact item presentation for loot (spec §33).
|
||||
*
|
||||
* Deliberately read-only: equipping, stat comparison, and slot replacement
|
||||
* belong to Slice 0.5.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-item-card',
|
||||
templateUrl: './item-card.component.html',
|
||||
styleUrl: './item-card.component.scss',
|
||||
})
|
||||
export class ItemCardComponent {
|
||||
readonly item = input.required<RewardItemSummary>();
|
||||
readonly quantity = input(1);
|
||||
|
||||
protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]);
|
||||
protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`);
|
||||
}
|
||||