feat(combat): replace the victory placeholder with the reward summary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user