diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 760ebeb..12ca2ca 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -86,6 +86,7 @@ describe('App', () => { name: 'Mara Ashfall', level: 7, experience: 320, + silver: 150, currentHp: 52, maxHp: 80, attack: 12, diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index e7646fc..f20178a 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -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[]; } 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 0bda59f..931db81 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 @@ -23,6 +23,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.'; diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index 0add925..4a2a1ee 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -20,6 +20,7 @@ const startedCombat: Combat = { artworkPath: '/images/monsters/ash-rat.png', }, events: [], + rewards: null, }; const afterAttack: Combat = { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index f39f1a0..f467f42 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -79,6 +79,7 @@ const startedCombat: Combat = { artworkPath: '/images/enemies/RoadBandit.png', }, events: [], + rewards: null, }; describe('HuntPageComponent', () => { diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 2eb3512..7e209ac 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -15,6 +15,7 @@ const character: CharacterResponse = { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, currentHp: 100, maxHp: 100, attack: 6, diff --git a/apps/web/src/app/shared/item-card/item-card.component.html b/apps/web/src/app/shared/item-card/item-card.component.html new file mode 100644 index 0000000..45a8b49 --- /dev/null +++ b/apps/web/src/app/shared/item-card/item-card.component.html @@ -0,0 +1,10 @@ + + + + @if (quantity() > 1) { + ×{{ quantity() }} + } + + {{ item().name }} + {{ rarityLabel() }} + diff --git a/apps/web/src/app/shared/item-card/item-card.component.scss b/apps/web/src/app/shared/item-card/item-card.component.scss new file mode 100644 index 0000000..7492f26 --- /dev/null +++ b/apps/web/src/app/shared/item-card/item-card.component.scss @@ -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); +} diff --git a/apps/web/src/app/shared/item-card/item-card.component.spec.ts b/apps/web/src/app/shared/item-card/item-card.component.spec.ts new file mode 100644 index 0000000..cd6caaa --- /dev/null +++ b/apps/web/src/app/shared/item-card/item-card.component.spec.ts @@ -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('[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'); + }); +}); diff --git a/apps/web/src/app/shared/item-card/item-card.component.ts b/apps/web/src/app/shared/item-card/item-card.component.ts new file mode 100644 index 0000000..b3904ec --- /dev/null +++ b/apps/web/src/app/shared/item-card/item-card.component.ts @@ -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> = { + 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(); + readonly quantity = input(1); + + protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]); + protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`); +}
{{ item().name }}
{{ rarityLabel() }}