diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 3155686..f221a36 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -84,7 +84,37 @@

Sieg

{{ combat.monster.name }} wurde besiegt.

-

Belohnungen werden im nächsten Schritt verarbeitet.

+ + @if (combat.rewards; as rewards) { +
+

Belohnungen

+ +
+
+
Erfahrung
+
+{{ rewards.experience }} XP
+
+
+
Silber
+
+{{ rewards.silver }}
+
+
+ + @if (rewards.items.length) { +

Beute

+ + } @else { +

Keine besondere Beute gefunden.

+ } +
+ } + diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index b487044..172f412 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -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; +} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 931db81..e20fd29 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -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('[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(); + }); }); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 8eea821..51187a6 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -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);