feat(combat): replace the victory placeholder with the reward summary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 09:34:21 +02:00
parent cf576133ff
commit 6711d51bcd
4 changed files with 176 additions and 1 deletions

View File

@@ -84,7 +84,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

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

@@ -239,4 +239,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,7 @@ import {
combatMonsterSpriteScale,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
@@ -29,6 +30,7 @@ const RIPOSTE_DELAY_MS = 260;
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);