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">
|
<div class="outcome outcome--won" data-combat-result="WON">
|
||||||
<h2 class="outcome__title">Sieg</h2>
|
<h2 class="outcome__title">Sieg</h2>
|
||||||
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
<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()">
|
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||||
Zur Jagd
|
Zur Jagd
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -328,6 +328,10 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outcome--won {
|
||||||
|
inline-size: min(32rem, 90%);
|
||||||
|
}
|
||||||
|
|
||||||
.outcome--won .outcome__title {
|
.outcome--won .outcome__title {
|
||||||
color: var(--ar-gold);
|
color: var(--ar-gold);
|
||||||
}
|
}
|
||||||
@@ -503,3 +507,61 @@
|
|||||||
max-inline-size: none;
|
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);
|
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,
|
combatMonsterSpriteScale,
|
||||||
runtimeMonsterArtworkPath,
|
runtimeMonsterArtworkPath,
|
||||||
} from '../../../shared/monster-artwork';
|
} from '../../../shared/monster-artwork';
|
||||||
|
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
|
||||||
import { CombatStore } from '../combat.store';
|
import { CombatStore } from '../combat.store';
|
||||||
|
|
||||||
interface CombatLogRound {
|
interface CombatLogRound {
|
||||||
@@ -29,6 +30,7 @@ const RIPOSTE_DELAY_MS = 260;
|
|||||||
selector: 'app-combat-page',
|
selector: 'app-combat-page',
|
||||||
templateUrl: './combat-page.component.html',
|
templateUrl: './combat-page.component.html',
|
||||||
styleUrl: './combat-page.component.scss',
|
styleUrl: './combat-page.component.scss',
|
||||||
|
imports: [ItemCardComponent],
|
||||||
})
|
})
|
||||||
export class CombatPageComponent implements OnInit {
|
export class CombatPageComponent implements OnInit {
|
||||||
protected readonly combatStore = inject(CombatStore);
|
protected readonly combatStore = inject(CombatStore);
|
||||||
|
|||||||
Reference in New Issue
Block a user