Merge branch 'worktree-slice-0.4-first-loot'

This commit is contained in:
Bastian Wagner
2026-08-20 10:33:39 +02:00
68 changed files with 6837 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -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', () => {

View File

@@ -9,6 +9,7 @@ export interface CharacterResponse {
name: string;
level: number;
experience: number;
silver: number;
currentHp: number;
maxHp: number;
attack: number;
@@ -105,4 +106,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[];
}

View File

@@ -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>

View File

@@ -457,6 +457,10 @@
text-transform: uppercase;
}
.outcome--won {
inline-size: min(32rem, 90%);
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
@@ -650,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;
}

View File

@@ -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' }) } },
@@ -209,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);
@@ -258,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();
});
});

View File

@@ -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 {
@@ -35,9 +37,11 @@ const STAGE_SHAKE_MS = 200;
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);
@@ -95,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',

View File

@@ -20,6 +20,7 @@ const startedCombat: Combat = {
artworkPath: '/images/monsters/ash-rat.png',
},
events: [],
rewards: null,
};
const afterAttack: Combat = {

View File

@@ -79,6 +79,7 @@ const startedCombat: Combat = {
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
rewards: null,
};
describe('HuntPageComponent', () => {

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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>
}

View File

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

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

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

View File

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

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