This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -35,6 +35,13 @@ export const routes: Routes = [
(module) => module.CombatPageComponent,
),
},
{
path: 'npc/:npcKey',
loadComponent: () =>
import('./features/npc/merchant-page.component').then(
(module) => module.MerchantPageComponent,
),
},
{
path: 'inventory',
loadComponent: () =>

View File

@@ -54,6 +54,8 @@ export interface LocationPointOfInterest {
xPercent: number;
yPercent: number;
enabled: boolean;
/** Set when this hotspot leads to a real NPC rather than authored text. */
npcKey?: string;
}
export interface LocationPrimaryAction {
@@ -65,6 +67,8 @@ export interface LocationPrimaryAction {
enabled: boolean;
/** Set when the action reveals the same result as a hotspot on the artwork. */
poiKey?: string;
/** Set when the action leads to a real NPC rather than authored text. */
npcKey?: string;
}
export interface EncounterPreview {
@@ -132,15 +136,20 @@ export interface MonsterSummary {
name: string;
level: number;
artworkPath: string;
/** Short atmosphere line for the encounter card; null when unauthored. */
flavorText: string | null;
}
export type HuntEncounterStatus = 'AVAILABLE' | 'IN_PROGRESS' | 'DEFEATED';
export type EncounterType = 'NORMAL' | 'RARE' | 'ELITE' | 'BOSS';
export interface HuntEncounter {
id: string;
monster: MonsterSummary;
dangerRating: DangerRating;
status: HuntEncounterStatus;
encounterType: EncounterType;
}
export interface HuntResult {
@@ -150,7 +159,18 @@ export interface HuntResult {
}
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatEventType =
| 'DAMAGE'
| 'HEAL'
| 'DEFEND'
| 'TELEGRAPH'
| 'INTERRUPT'
| 'STATUS_APPLIED'
| 'STATUS_DAMAGE'
| 'STATUS_EXPIRED'
| 'COMBAT_WON'
| 'COMBAT_LOST';
export type StatusEffectType = 'BLEED';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
export type CombatMonsterIntent = 'HEAVY_ATTACK';
@@ -162,6 +182,13 @@ export interface CombatEvent {
source: CombatSide;
target: CombatSide;
amount?: number;
statusEffect?: StatusEffectType;
}
export interface CombatStatusEffect {
type: StatusEffectType;
remainingRounds: number;
damagePerRound: number;
}
export interface CombatPlayer {
@@ -170,6 +197,7 @@ export interface CombatPlayer {
currentHp: number;
potionsRemaining: number;
potionsMax: number;
statusEffects: CombatStatusEffect[];
}
export interface CombatMonster {
@@ -194,22 +222,54 @@ export interface Combat {
export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC';
export interface RewardItemSummary {
export type ItemType =
| 'EQUIPMENT'
| 'TRADE_GOOD'
| 'TROPHY'
| 'QUEST_ITEM'
| 'CONSUMABLE';
/** The carrying bucket a trade good counts against (slice 0.7.5 §3). */
export type LootCategory = 'HIDE' | 'RAIDER_TROPHY';
export interface LootCapacityBag {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
}
export interface CombatRewardItem {
characterItemId: string;
item: RewardItemSummary;
quantity: number;
export interface LootCapacity {
category: LootCategory;
current: number;
capacity: number;
/** The active bag behind `capacity`, or null at the bagless default. */
bag: LootCapacityBag | null;
}
export interface RewardItemSummary {
key: string;
name: string;
type: ItemType;
rarity: ItemRarity;
iconPath: string;
lootCategory: LootCategory | null;
}
export interface CombatRewardItem {
/** Null when the whole drop was left behind — nothing was stored. */
characterItemId: string | null;
item: RewardItemSummary;
/** How much reached the character. 0 when the bag was already full. */
quantity: number;
/** How much the bag refused (slice 0.7.5 §9). */
quantityLeftBehind: number;
}
/** Items only: a normal victory grants no Silver or XP (slice 0.7 V2 §7). */
export interface CombatReward {
silver: number;
items: CombatRewardItem[];
/** Carrying state after this reward, so the screen needs no second call. */
capacities: LootCapacity[];
}
export type EquipmentSlot =
@@ -276,16 +336,125 @@ export interface ReputationEntry {
nextThreshold: number | null;
}
export interface TurnInResult {
turnInKey: string;
quantityConsumed: number;
silverGranted: number;
reputationResult: {
factionKey: string;
previousReputation: number;
newReputation: number;
previousRank: string;
newRank: string;
rankChanged: boolean;
};
/**
* NPC, shop and trade-in transport types (Playable Slice 0.8).
*
* Replaces `TurnInResult` from Slice 0.6.5: trade-in now happens with a
* named merchant and pays reputation and renown alongside Silver.
*/
export type NpcMarker =
| 'MERCHANT'
| 'EXCHANGE'
| 'QUEST_AVAILABLE'
| 'QUEST_TURN_IN';
export interface NpcSummary {
id: string;
key: string;
name: string;
title: string | null;
portraitPath: string;
markers: NpcMarker[];
}
export type NpcActionType =
| 'TALK'
| 'OPEN_SHOP'
| 'OPEN_EXCHANGE'
| 'VIEW_QUESTS';
export interface NpcAction {
type: NpcActionType;
label: string;
key: string | null;
}
export interface DialogueNodeView {
key: string;
text: string;
responses: Array<{ key: string; text: string; targetNodeKey: string | null }>;
}
export interface NpcInteraction {
npc: {
id: string;
key: string;
name: string;
title: string | null;
description: string | null;
portraitPath: string;
artworkPath: string | null;
capabilities: string[];
};
dialogue: DialogueNodeView | null;
availableActions: NpcAction[];
}
export interface ExchangeOffer {
itemKey: string;
itemName: string;
iconPath: string;
quantityCarried: number;
inputQuantity: number;
silverPerStep: number;
reputationPerStep: number;
factionKey: string;
factionName: string;
renownMilestoneKey: string | null;
}
export interface ExchangeView {
profileKey: string;
profileName: string;
npcKey: string;
offers: ExchangeOffer[];
capacities: LootCapacity[];
}
export interface ExchangeResult {
profileKey: string;
consumed: Array<{ itemKey: string; itemName: string; quantity: number }>;
rewards: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
balances: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
reputationRankChanged: boolean;
newReputationRank: string | null;
renownMilestonesCompleted: string[];
capacities: LootCapacity[];
}
export interface ShopOfferView {
itemKey: string;
itemName: string;
itemDescription: string;
iconPath: string;
currencyType: string;
price: number;
quantity: number;
unlocked: boolean;
affordable: boolean;
}
export interface ShopView {
shopKey: string;
shopName: string;
npcKey: string;
silver: number;
offers: ShopOfferView[];
}
export interface ShopPurchaseResult {
shopKey: string;
itemKey: string;
itemName: string;
quantity: number;
silverSpent: number;
silverBalance: number;
}

View File

@@ -108,24 +108,52 @@ describe('GameApiService', () => {
req.flush([]);
});
it('submits a turn-in with only turnInKey and quantity', () => {
service.turnIn('ash-pelt-border-guard', 3).subscribe();
it('submits a trade-in with only item keys and quantities', () => {
// Prices and rewards are never sent: the server reads them from the
// exchange rules (slice 0.8 §8).
service
.tradeIn('borin-quartermaster', [{ itemKey: 'ash-pelt', quantity: 3 }])
.subscribe();
const req = http.expectOne('/api/turn-ins');
const req = http.expectOne('/api/merchants/borin-quartermaster/trade-in');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ turnInKey: 'ash-pelt-border-guard', quantity: 3 });
req.flush({
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
expect(req.request.body).toEqual({
items: [{ itemKey: 'ash-pelt', quantity: 3 }],
});
req.flush({});
});
it('fetches the trade-in view for a merchant', () => {
service.getTradeIn('borin-quartermaster').subscribe();
const req = http.expectOne('/api/merchants/borin-quartermaster/trade-in');
expect(req.request.method).toBe('GET');
req.flush({});
});
it('fetches an NPC interaction by key', () => {
service.getNpcInteraction('borin-quartermaster').subscribe();
const req = http.expectOne(
'/api/npcs/borin-quartermaster/interaction',
);
expect(req.request.method).toBe('GET');
req.flush({});
});
it('buys from a shop without sending a price', () => {
service
.purchase('borin-quartermaster', 'small-healing-potion', 1)
.subscribe();
const req = http.expectOne(
'/api/merchants/borin-quartermaster/shop/purchase',
);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
itemKey: 'small-healing-potion',
quantity: 1,
});
req.flush({});
});
});

View File

@@ -11,8 +11,14 @@ import {
HuntResult,
InventoryResponse,
LocationInteractionResult,
LootCapacity,
ExchangeResult,
ExchangeView,
NpcInteraction,
NpcSummary,
ReputationEntry,
TurnInResult,
ShopPurchaseResult,
ShopView,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
@@ -74,6 +80,10 @@ export class GameApiService {
return this.http.get<InventoryResponse>('/api/inventory');
}
getLootCapacities(): Observable<LootCapacity[]> {
return this.http.get<LootCapacity[]>('/api/loot-bags/capacities');
}
getEquipment(): Observable<EquipmentResponse> {
return this.http.get<EquipmentResponse>('/api/equipment');
}
@@ -86,7 +96,52 @@ export class GameApiService {
return this.http.get<ReputationEntry[]>('/api/reputation');
}
turnIn(turnInKey: string, quantity: number): Observable<TurnInResult> {
return this.http.post<TurnInResult>('/api/turn-ins', { turnInKey, quantity });
getLocationNpcs(locationId: string): Observable<NpcSummary[]> {
return this.http.get<NpcSummary[]>(
`/api/locations/${encodeURIComponent(locationId)}/npcs`,
);
}
getNpcInteraction(npcKey: string): Observable<NpcInteraction> {
return this.http.get<NpcInteraction>(
`/api/npcs/${encodeURIComponent(npcKey)}/interaction`,
);
}
getTradeIn(merchantKey: string): Observable<ExchangeView> {
return this.http.get<ExchangeView>(
`/api/merchants/${encodeURIComponent(merchantKey)}/trade-in`,
);
}
/**
* Hands goods over. Only keys and quantities travel -- prices and rewards
* are the server's to decide (slice 0.8 §8).
*/
tradeIn(
merchantKey: string,
items: Array<{ itemKey: string; quantity: number }>,
): Observable<ExchangeResult> {
return this.http.post<ExchangeResult>(
`/api/merchants/${encodeURIComponent(merchantKey)}/trade-in`,
{ items },
);
}
getShop(merchantKey: string): Observable<ShopView> {
return this.http.get<ShopView>(
`/api/merchants/${encodeURIComponent(merchantKey)}/shop`,
);
}
purchase(
merchantKey: string,
itemKey: string,
quantity: number,
): Observable<ShopPurchaseResult> {
return this.http.post<ShopPurchaseResult>(
`/api/merchants/${encodeURIComponent(merchantKey)}/shop/purchase`,
{ itemKey, quantity },
);
}
}

View File

@@ -9,7 +9,14 @@ const runningCombat: Combat = {
id: 'combat-running',
status: 'ACTIVE',
round: 4,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 },
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 62,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'road-bandit',
name: 'Road Bandit',

View File

@@ -17,6 +17,23 @@
<span class="bar__fill" [style.inline-size.%]="playerHpPercent()"></span>
<span class="bar__text">{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
</div>
@if (combat.player.statusEffects.length) {
<ul class="statuses" data-combat-statuses aria-label="Active effects">
@for (effect of combat.player.statusEffects; track effect.type) {
<li
class="status"
[class]="'status--' + effect.type.toLowerCase()"
[attr.data-combat-status]="effect.type"
>
<span class="status__glyph" aria-hidden="true"></span>
<span class="status__label">
{{ statusEffectLabel(effect.type) }} · {{ effect.remainingRounds }}
</span>
</li>
}
</ul>
}
</div>
</div>
@@ -137,28 +154,43 @@
@if (combat.rewards; as rewards) {
<section class="rewards" data-combat-rewards aria-label="Rewards">
<h3 class="rewards__title">Rewards</h3>
@if (rewards.silver) {
<dl class="rewards__currencies">
<div class="rewards__currency" data-reward-silver>
<dt>Silver</dt>
<dd>+{{ rewards.silver }}</dd>
</div>
</dl>
@if (lootGroups(); as groups) {
@if (groups.length) {
@for (group of groups; track group.key) {
<h3 class="rewards__title" [attr.data-reward-group]="group.key">
{{ group.title }}
</h3>
<ul class="rewards__loot">
@for (reward of group.items; track reward.characterItemId) {
<li>
<app-item-card [item]="reward.item" [quantity]="reward.quantity" />
</li>
}
</ul>
}
} @else {
<p class="outcome__hint" data-reward-empty>No notable loot found.</p>
}
}
@if (rewards.items.length) {
<h3 class="rewards__title">Loot</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>No notable loot found.</p>
@if (leftBehind(); as refused) {
@if (refused.length) {
<h3 class="rewards__title rewards__title--refused">Left Behind</h3>
<ul class="rewards__refused" data-reward-left-behind>
@for (entry of refused; track entry.key) {
<li [attr.data-reward-refused]="entry.key">
{{ entry.name }} ×{{ entry.quantity }} — no room left
</li>
}
</ul>
}
}
@if (rewards.capacities.length) {
<app-loot-capacity-strip
class="rewards__capacities"
[capacities]="rewards.capacities"
/>
}
</section>
}

View File

@@ -100,6 +100,45 @@
max-inline-size: 22rem;
}
/* ---------- ongoing effects ---------- */
/* Sits directly under the health bar it is eating away at, so the cause of
the drain is next to the number that drops (spec §4). */
.statuses {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--ar-border);
border-radius: 0.15rem;
background: rgb(0 0 0 / 0.35);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
}
/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and
the shape carries the meaning on its own. */
.status__glyph {
inline-size: 0.55rem;
block-size: 0.7rem;
background: currentcolor;
clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%);
}
.status--bleed {
border-color: rgb(158 46 42 / 0.75);
color: #d8736c;
}
.fighter--monster .fighter__meter {
justify-items: end;
text-align: end;
@@ -670,7 +709,46 @@
justify-content: flex-start;
}
.fighter--monster .fighter__meter {
/* ---------- ongoing effects ---------- */
/* Sits directly under the health bar it is eating away at, so the cause of
the drain is next to the number that drops (spec §4). */
.statuses {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--ar-border);
border-radius: 0.15rem;
background: rgb(0 0 0 / 0.35);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
}
/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and
the shape carries the meaning on its own. */
.status__glyph {
inline-size: 0.55rem;
block-size: 0.7rem;
background: currentcolor;
clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%);
}
.status--bleed {
border-color: rgb(158 46 42 / 0.75);
color: #d8736c;
}
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}
@@ -707,30 +785,25 @@
text-transform: uppercase;
}
.rewards__currencies {
display: flex;
gap: var(--ar-space-6);
margin: 0;
/* Set apart from the loot headings: this is what the player did NOT get,
and it must not read as another reward row (slice 0.7.5 §9). */
.rewards__title--refused {
color: var(--ar-danger);
}
.rewards__currency {
.rewards__refused {
display: grid;
gap: var(--ar-space-1);
justify-items: center;
}
.rewards__currency dt {
margin: 0;
padding: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
list-style: none;
text-align: center;
}
.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__capacities {
margin-block-start: var(--ar-space-2);
}
.rewards__loot {

View File

@@ -2,16 +2,33 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import type {
Combat,
CombatRewardItem,
LootCapacity,
} from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { WorldStore } from '../../world/world.store';
import { CombatPageComponent } from './combat-page.component';
// Roomy enough that no test trips the "full" styling unless it asks to.
const FULL_CAPACITIES: LootCapacity[] = [
{ category: 'HIDE', current: 1, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
];
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 95,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'ash-rat',
name: 'Ash Rat',
@@ -426,7 +443,7 @@ describe('CombatPageComponent', () => {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
rewards: { silver: 6, items: [] },
rewards: { items: [], capacities: FULL_CAPACITIES },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 },
@@ -522,57 +539,292 @@ describe('CombatPageComponent', () => {
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
});
describe('Bleeding (spec §4)', () => {
it('shows an active effect with the rounds it still has to run', async () => {
const fixture = await setup({
...activeCombat,
player: {
...activeCombat.player,
statusEffects: [{ type: 'BLEED', remainingRounds: 2, damagePerRound: 5 }],
},
});
const element = fixture.nativeElement as HTMLElement;
const badge = element.querySelector('[data-combat-status="BLEED"]');
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain('Bleeding');
expect(badge?.textContent).toContain('2');
});
it('shows no effect strip while the player is unafflicted', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-statuses]')).toBeNull();
});
it('reads the status events in the combat log', async () => {
const fixture = await setup({
...activeCombat,
player: {
...activeCombat.player,
statusEffects: [{ type: 'BLEED', remainingRounds: 1, damagePerRound: 5 }],
},
events: [
{
round: 1,
sequence: 1,
type: 'STATUS_APPLIED',
source: 'MONSTER',
target: 'PLAYER',
amount: 2,
statusEffect: 'BLEED',
},
{
round: 1,
sequence: 2,
type: 'STATUS_DAMAGE',
source: 'MONSTER',
target: 'PLAYER',
amount: 5,
statusEffect: 'BLEED',
},
{
round: 1,
sequence: 3,
type: 'STATUS_EXPIRED',
source: 'MONSTER',
target: 'PLAYER',
statusEffect: 'BLEED',
},
],
});
const element = fixture.nativeElement as HTMLElement;
const log = element.querySelector('.combat__log-body')?.textContent ?? '';
expect(log).toContain('inflicts Bleeding');
expect(log).toContain('Bleeding costs Aric Duskwalker 5 HP');
expect(log).toContain('Bleeding fades');
});
});
const wonWithRewards: Combat = {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
rewards: { silver: 6, items: [] },
rewards: { items: [], capacities: FULL_CAPACITIES },
};
it('shows the granted silver on the victory screen', async () => {
const fixture = await setup(wonWithRewards);
const ashenPelt: CombatRewardItem = {
characterItemId: 'character-item-pelt',
item: {
key: 'ash-pelt',
name: 'Ashen Pelt',
type: 'TRADE_GOOD',
lootCategory: 'HIDE',
rarity: 'COMMON',
iconPath: '/images/items/ash-pelt.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const banditBlade: CombatRewardItem = {
characterItemId: 'character-item-blade',
item: {
key: 'bandit-blade',
name: 'Bandit Blade',
type: 'EQUIPMENT',
lootCategory: null,
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const healingPotion: CombatRewardItem = {
characterItemId: 'character-item-potion',
item: {
key: 'small-healing-potion',
name: 'Small Healing Potion',
type: 'CONSUMABLE',
lootCategory: null,
rarity: 'COMMON',
iconPath: '/images/items/small-healing-potion.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
it('shows the reward panel without any currency row', async () => {
const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6');
// R16: the XP block is removed entirely, not hidden -- guard against it
// reappearing in the markup.
// Spec §9: no XP or Silver rows. Both blocks are removed from the markup
// rather than hidden, so guard against either reappearing.
expect(element.querySelector('[data-reward-silver]')).toBeNull();
expect(element.querySelector('[data-reward-experience]')).toBeNull();
});
it('hides the silver tile entirely when the kill granted none', async () => {
// Every monster seeded in this slice rolls silverMin/silverMax = 0 (design
// R7), so without this guard the victory screen advertises "Silver +0"
// after every single fight in the shipped content.
it('separates trade goods, equipment, and consumables in the loot summary', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { silver: 0, items: [] },
rewards: { items: [banditBlade, ashenPelt, healingPotion], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-silver]')).toBeNull();
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
const groups = [...element.querySelectorAll('[data-reward-group]')];
expect(groups.map((group) => group.getAttribute('data-reward-group'))).toEqual([
'trade-goods',
'equipment',
'consumables',
]);
expect(groups.map((group) => group.textContent?.trim())).toEqual([
'Trade Goods',
'Equipment',
'Consumables',
]);
});
it('omits a category the fight did not drop anything for', async () => {
const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
const groups = [...element.querySelectorAll('[data-reward-group]')];
expect(groups).toHaveLength(1);
expect(groups[0].getAttribute('data-reward-group')).toBe('trade-goods');
});
it('files a trophy with the trade goods, because both are merchant fodder', async () => {
const insignia: CombatRewardItem = {
characterItemId: 'character-item-insignia',
item: {
key: 'bandit-insignia',
name: 'Raider Insignia',
type: 'TROPHY',
lootCategory: 'RAIDER_TROPHY',
rarity: 'COMMON',
iconPath: '/images/items/bandit-insignia.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const fixture = await setup({ ...wonWithRewards, rewards: { items: [insignia], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('[data-reward-group="trade-goods"]'),
).toBeTruthy();
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Raider Insignia',
);
});
describe('full loot bags (slice 0.7.5 §9, §10)', () => {
const refusedPelt: CombatRewardItem = {
...ashenPelt,
characterItemId: null,
quantity: 0,
quantityLeftBehind: 1,
};
const fullHides: LootCapacity[] = [
{ category: 'HIDE', current: 5, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
];
it('names what the bag refused instead of dropping it silently', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
const refused = element.querySelector('[data-reward-left-behind]');
expect(refused).toBeTruthy();
expect(refused?.textContent).toContain('Ashen Pelt');
expect(refused?.textContent).toContain('1');
});
it('keeps a fully refused drop out of the loot the player actually got', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// Nothing was banked, so no Trade Goods heading and no item card.
expect(element.querySelector('[data-reward-group="trade-goods"]')).toBeNull();
expect(element.querySelector('[data-item-name]')).toBeNull();
});
it('lists a partial grant under both loot and left behind', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: {
items: [{ ...ashenPelt, quantity: 1, quantityLeftBehind: 1 }],
capacities: fullHides,
},
});
const element = fixture.nativeElement as HTMLElement;
// §10: granted 1, left behind 1 -- the player has to see both halves.
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Ashen Pelt',
);
expect(
element.querySelector('[data-reward-refused="ash-pelt"]')?.textContent,
).toContain('1');
});
it('still shows equipment that landed while the hide bag was full', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt, banditBlade], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// §9: equipment must not be lost to a full trade-good bag.
expect(element.querySelector('[data-reward-group="equipment"]')).toBeTruthy();
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Bandit Blade',
);
expect(element.querySelector('[data-reward-left-behind]')).toBeTruthy();
});
it('says nothing about left-behind loot when everything fit', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-left-behind]')).toBeNull();
});
it('shows the carrying state after the fight', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// §11/§12: the player learns the trip is over on the victory screen,
// without a second request.
expect(element.querySelector('[data-loot-capacities]')).toBeTruthy();
expect(
element.querySelector('[data-loot-capacity="HIDE"] [data-loot-capacity-full]'),
).toBeTruthy();
});
});
it('renders a dropped item with its icon, name, and rarity', async () => {
const fixture = await setup({
...wonWithRewards,
monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Road Bandit', currentHp: 0 },
rewards: {
silver: 12,
items: [
{
characterItemId: 'character-item-1',
item: {
key: 'bandit-blade',
name: 'Bandit Blade',
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
},
],
},
rewards: { items: [banditBlade], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
@@ -599,12 +851,12 @@ describe('CombatPageComponent', () => {
// exactly what the server persisted, without rerolling anything.
const fixture = await setup({
...wonWithRewards,
rewards: { silver: 12, items: [] },
rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12');
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Ashen Pelt');
});
it('still shows a plain victory when the server reports no reward record', async () => {

View File

@@ -1,6 +1,14 @@
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
import type {
Combat,
CombatAction,
CombatEvent,
CombatEventType,
CombatRewardItem,
ItemType,
StatusEffectType,
} from '../../../core/api/game-api.models';
import {
combatMonsterSpriteScale,
monsterCutoutPath,
@@ -8,6 +16,7 @@ import {
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
import { LootCapacityStripComponent } from '../../../shared/loot-capacity-strip/loot-capacity-strip.component';
import { WorldStore } from '../../world/world.store';
import { CombatStore } from '../combat.store';
@@ -16,6 +25,41 @@ interface CombatLogRound {
events: CombatEvent[];
}
interface LootGroup {
key: string;
title: string;
items: CombatRewardItem[];
}
/** A drop the bag refused, rendered apart from what was actually banked. */
interface LeftBehindEntry {
key: string;
name: string;
quantity: number;
}
// Reward rows are grouped by what the item *is*, not by which enemy dropped
// it (spec §9). Trophies sit with the trade goods: an insignia is turned in
// at the merchant exactly like a pelt.
const LOOT_GROUPS: ReadonlyArray<{ key: string; title: string; types: ItemType[] }> = [
{ key: 'trade-goods', title: 'Trade Goods', types: ['TRADE_GOOD', 'TROPHY'] },
{ key: 'equipment', title: 'Equipment', types: ['EQUIPMENT'] },
{ key: 'consumables', title: 'Consumables', types: ['CONSUMABLE'] },
{ key: 'quest-items', title: 'Quest Items', types: ['QUEST_ITEM'] },
];
const STATUS_EFFECT_LABELS: Readonly<Record<StatusEffectType, string>> = {
BLEED: 'Bleeding',
};
// Ongoing-effect events belong to the round's aftermath, not to the enemy's
// turn: they still fire when Shield Bash interrupted the monster outright.
const STATUS_EVENT_TYPES: ReadonlySet<CombatEventType> = new Set([
'STATUS_APPLIED',
'STATUS_DAMAGE',
'STATUS_EXPIRED',
]);
type CombatPhase = 'idle' | 'attacking' | 'hit';
// The monster is a single cut-out with no sheets, so its beats are pure
// CSS transforms and run offset from the player's: it flinches when the
@@ -42,7 +86,7 @@ const DAMAGING_ACTIONS: ReadonlySet<CombatAction> = new Set(['ATTACK', 'HEAVY_ST
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
styleUrl: './combat-page.component.scss',
imports: [ItemCardComponent],
imports: [ItemCardComponent, LootCapacityStripComponent],
})
export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
@@ -105,9 +149,9 @@ export class CombatPageComponent implements OnInit {
}
if (after.status === 'WON') {
// The server already granted silver and any item drops; pull the
// authoritative character so the HUD matches (spec §35). Renown is
// not granted here -- it comes from milestones only.
// A victory grants items only -- no Silver, no Renown (slice 0.7 V2
// §7) -- but the character's HP moved, so pull the authoritative
// character for the HUD (spec §35).
void this.worldStore.refreshCharacter();
}
@@ -117,10 +161,13 @@ export class CombatPageComponent implements OnInit {
);
this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle');
const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER');
const monsterEvent = roundEvents.find(
(event) => event.source === 'MONSTER' && !STATUS_EVENT_TYPES.has(event.type),
);
if (!monsterEvent) {
// No reply this round: either the fight just ended, or SHIELD_BASH
// interrupted the monster's turn outright.
// interrupted the monster's turn outright. Any bleed tick that still
// landed comes along with this reveal.
this.displayed.set(after);
return;
}
@@ -162,7 +209,7 @@ export class CombatPageComponent implements OnInit {
}
this.phase.set('hit');
this.monsterPhase.set('lunge');
this.monsterPhase.set(monsterEvent.type === 'DAMAGE' ? 'lunge' : 'idle');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
@@ -228,6 +275,46 @@ export class CombatPageComponent implements OnInit {
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
/**
* The loot summary, split into the categories the player thinks in (spec
* §9). Empty groups are dropped so a fight that only yielded a pelt shows
* one heading rather than four.
*/
protected lootGroups(): LootGroup[] {
const items = this.displayed()?.rewards?.items ?? [];
return LOOT_GROUPS.map((group) => ({
key: group.key,
title: group.title,
// A drop that was entirely refused belongs under "left behind", not in
// the loot the player walked away with (slice 0.7.5 §9).
items: items.filter(
(reward) =>
reward.quantity > 0 && group.types.includes(reward.item.type),
),
})).filter((group) => group.items.length > 0);
}
protected statusEffectLabel(type: StatusEffectType): string {
return STATUS_EFFECT_LABELS[type];
}
/**
* What a full bag refused (slice 0.7.5 §9).
*
* Never silently dropped: the player has to learn that the trip is over
* before they walk into another fight and lose the same good again.
*/
protected leftBehind(): LeftBehindEntry[] {
return (this.displayed()?.rewards?.items ?? [])
.filter((reward) => reward.quantityLeftBehind > 0)
.map((reward) => ({
key: reward.item.key,
name: reward.item.name,
quantity: reward.quantityLeftBehind,
}));
}
protected monsterIntentLabel(): string | null {
const combat = this.displayed();
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
@@ -279,6 +366,20 @@ export class CombatPageComponent implements OnInit {
return `${playerName} interrupts ${monsterName}'s attack.`;
}
const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect';
if (event.type === 'STATUS_APPLIED') {
return `${monsterName} inflicts ${effect} on ${playerName}.`;
}
if (event.type === 'STATUS_DAMAGE') {
return `${effect} costs ${playerName} ${event.amount} HP.`;
}
if (event.type === 'STATUS_EXPIRED') {
return `${effect} fades from ${playerName}.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} has been defeated.`;
}

View File

@@ -10,7 +10,14 @@ const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'ash-rat',
name: 'Ash Rat',

View File

@@ -1,4 +1,8 @@
<article class="encounter-card" [class.encounter-card--settled]="settled">
<article
class="encounter-card"
[class.encounter-card--settled]="settled"
[class.encounter-card--rare]="rare"
>
<div class="encounter-card__crest">
@if (iconPath(); as icon) {
<img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
@@ -14,6 +18,10 @@
decoding="async"
/>
@if (rare) {
<span class="encounter-card__rare" data-encounter-rare>{{ rareLabel }}</span>
}
@if (defeated) {
<img
class="encounter-card__defeated-mark"
@@ -41,3 +49,7 @@
{{ actionLabel }}
</button>
</article>
@if (encounter.monster.flavorText; as flavor) {
<p class="encounter-card__flavor" data-encounter-flavor>{{ flavor }}</p>
}

View File

@@ -1,5 +1,7 @@
:host {
display: block;
display: grid;
gap: 0.5rem;
justify-items: center;
}
/*
@@ -21,6 +23,13 @@
background-size: 100% 100%;
}
/* A rare find has to be legible across the row before the player reads a
single word, so the whole frame carries a gilded halo -- not just the
corner tag inside the artwork panel. */
.encounter-card--rare {
filter: drop-shadow(0 0 0.85rem rgb(214 178 107 / 0.55));
}
/* A settled encounter is out of play, so the card recedes: the artwork loses
its colour and the whole frame dims. */
.encounter-card--settled .encounter-card__artwork {
@@ -64,6 +73,24 @@
place-items: end center;
}
/* Pinned inside the artwork panel: the painted frame has no free band, and
the crest sits centred on the card's top edge, so the corner is the one
place a tag can live without covering something. */
.encounter-card__rare {
position: absolute;
inset-block-start: 3%;
inset-inline-start: 2%;
padding: 0.15em 0.6em;
border: 1px solid rgb(214 178 107 / 0.85);
border-radius: 0.15rem;
color: #f2e2bd;
background: rgb(28 20 12 / 0.85);
font-size: clamp(0.55rem, 3.2cqw, 0.75rem);
letter-spacing: 0.12em;
line-height: 1.2;
text-transform: uppercase;
}
/* Sits over the artwork panel rather than the whole card, so the name and
the danger badge stay readable under it. */
.encounter-card__defeated-mark {
@@ -166,6 +193,23 @@
outline-offset: -3px;
}
/* ---------- flavor line ---------- */
/* Outside the painted frame on purpose: every band inside it is already
spoken for, and an atmosphere line is the one piece of the card that may
wrap to a second row. */
.encounter-card__flavor {
max-inline-size: 20rem;
margin: 0;
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
font-style: italic;
line-height: 1.35;
text-align: center;
text-wrap: balance;
}
@media (prefers-reduced-motion: no-preference) {
.encounter-card {
transition: filter var(--ar-motion-base);
@@ -175,6 +219,11 @@
filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3));
}
/* A rare card must not lose its halo the moment the pointer arrives. */
.encounter-card--rare:not(.encounter-card--settled):hover {
filter: drop-shadow(0 0 1.1rem rgb(214 178 107 / 0.65));
}
.encounter-card__attack {
transition:
color var(--ar-motion-fast),

View File

@@ -10,9 +10,11 @@ const dawnwolfEncounter: HuntEncounter = {
name: 'Dawnwolf',
level: 3,
artworkPath: '/images/enemies/Dawnwolf.png',
flavorText: null,
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
encounterType: 'NORMAL',
};
const ashRatEncounter: HuntEncounter = {
@@ -22,9 +24,11 @@ const ashRatEncounter: HuntEncounter = {
name: 'Ash Rat',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
flavorText: null,
},
dangerRating: 'WEAK',
status: 'AVAILABLE',
encounterType: 'NORMAL',
};
function render(encounter: HuntEncounter): HTMLElement {
@@ -92,6 +96,71 @@ describe('EncounterCardComponent', () => {
expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key);
});
describe('rare encounters (spec §9)', () => {
const charredRaider: HuntEncounter = {
...dawnwolfEncounter,
id: 'encounter-id-rare',
monster: {
key: 'charred-looter',
name: 'Charred Raider',
level: 2,
artworkPath: '/images/monsters/charred-looter.png',
flavorText: null,
},
encounterType: 'RARE',
};
it('marks a rare encounter with a tag and a distinct frame', () => {
const element = render(charredRaider);
expect(element.querySelector('[data-encounter-rare]')?.textContent?.trim()).toBe(
'Rare',
);
expect(element.querySelector('.encounter-card')?.classList).toContain(
'encounter-card--rare',
);
});
it('leaves an ordinary encounter unmarked', () => {
const element = render(dawnwolfEncounter);
expect(element.querySelector('[data-encounter-rare]')).toBeNull();
expect(element.querySelector('.encounter-card')?.classList).not.toContain(
'encounter-card--rare',
);
});
it('reads the encounter type from the server rather than the monster key', () => {
// The same rare monster, downgraded to NORMAL by the server, must lose
// its marking -- the card may not decide rarity from content it knows.
const element = render({ ...charredRaider, encounterType: 'NORMAL' });
expect(element.querySelector('[data-encounter-rare]')).toBeNull();
});
});
describe('flavor text (spec §9)', () => {
it('shows the flavor line the server sent for the monster', () => {
const element = render({
...dawnwolfEncounter,
monster: {
...dawnwolfEncounter.monster,
flavorText: 'It hunts at the hour the light turns.',
},
});
expect(element.querySelector('[data-encounter-flavor]')?.textContent).toContain(
'It hunts at the hour the light turns.',
);
});
it('renders nothing at all for a monster without a flavor line', () => {
const element = render(dawnwolfEncounter);
expect(element.querySelector('[data-encounter-flavor]')).toBeNull();
});
});
it('leaves an available encounter unmarked and interactive', () => {
const element = render(dawnwolfEncounter);

View File

@@ -27,6 +27,22 @@ export class EncounterCardComponent {
return this.encounter.status !== 'AVAILABLE';
}
/**
* An uncommon find has to be recognisable at a glance (spec §9). The card
* reads the server's encounter type rather than checking for a monster key,
* so marking a future enemy as rare stays a content change.
*/
protected get rare(): boolean {
return this.encounter.encounterType !== 'NORMAL';
}
protected get rareLabel(): string {
return this.encounter.encounterType === 'RARE'
? 'Rare'
: this.encounter.encounterType.charAt(0) +
this.encounter.encounterType.slice(1).toLowerCase();
}
protected get actionLabel(): string {
if (this.defeated) {
return 'Defeated';

View File

@@ -10,6 +10,13 @@
<section class="hunt-page__results" [attr.aria-label]="'Encounters at ' + location.name">
<p class="hunt-page__results-heading">{{ location.name }} — Encounters</p>
@if (huntingStore.lootCapacities().length) {
<app-loot-capacity-strip
class="hunt-page__capacities"
[capacities]="huntingStore.lootCapacities()"
/>
}
<div class="hunt-page__encounters">
@for (encounter of huntingStore.encounters(); track encounter.id) {
<app-encounter-card [encounter]="encounter" (attack)="onAttack($event)" />

View File

@@ -43,6 +43,15 @@
font-size: 1.3rem;
}
/* Between the heading and the cards: the player sees what they can still
carry before choosing the next fight, not after it (spec §12). */
.hunt-page__capacities {
display: block;
margin-block-end: var(--ar-space-4);
padding-block-end: var(--ar-space-3);
border-block-end: 1px solid var(--ar-border);
}
.hunt-page__encounters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));

View File

@@ -3,7 +3,12 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import type {
Combat,
CurrentLocationResponse,
HuntResult,
LootCapacity,
} from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import {
burnedRoadFixture,
@@ -23,9 +28,16 @@ const threeEncounterHunt: HuntResult = {
encounters: [
{
id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' },
monster: {
key: 'ash-rat',
name: 'Ash Rat',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
flavorText: null,
},
dangerRating: 'WEAK',
status: 'AVAILABLE',
encounterType: 'NORMAL',
},
{
id: 'encounter-2',
@@ -34,15 +46,24 @@ const threeEncounterHunt: HuntResult = {
name: 'Road Bandit',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
flavorText: null,
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
encounterType: 'NORMAL',
},
{
id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
monster: {
key: 'charred-looter',
name: 'Charred Raider',
level: 2,
artworkPath: '/images/enemies/CharredRaider.png',
flavorText: null,
},
dangerRating: 'STRONG',
status: 'AVAILABLE',
encounterType: 'RARE',
},
],
};
@@ -51,7 +72,14 @@ const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'road-bandit',
name: 'Road Bandit',
@@ -78,6 +106,8 @@ describe('HuntPageComponent', () => {
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
loadActiveHunt: ReturnType<typeof vi.fn>;
loadLootCapacities: ReturnType<typeof vi.fn>;
lootCapacities: ReturnType<typeof signal<LootCapacity[]>>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
@@ -90,7 +120,11 @@ describe('HuntPageComponent', () => {
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
async function setup(
location: CurrentLocationResponse | null,
hunt: HuntResult | null = null,
capacities: LootCapacity[] = [],
) {
worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) };
const currentHunt = signal(hunt);
huntingStore = {
@@ -101,6 +135,8 @@ describe('HuntPageComponent', () => {
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
loadActiveHunt: vi.fn(() => Promise.resolve()),
loadLootCapacities: vi.fn(() => Promise.resolve()),
lootCapacities: signal<LootCapacity[]>(capacities),
selectEncounter: vi.fn(),
};
combatStore = {
@@ -158,27 +194,69 @@ describe('HuntPageComponent', () => {
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
it('renders one card per encounter the server rolled, in order', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Ash Rat[\s\S]*Road Bandit[\s\S]*Ash Rat/);
expect(
element.querySelectorAll(
'.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]',
).length,
).toBe(2);
expect(
element.querySelectorAll(
'.encounter-card__artwork[src="/images/combat/sprites/road-bandit-620.png"]',
).length,
).toBe(1);
expect(element.textContent).toMatch(
/Ash Rat[\s\S]*Road Bandit[\s\S]*Charred Raider/,
);
for (const sprite of [
'/images/combat/sprites/ash-rat-760.png',
'/images/combat/sprites/road-bandit-620.png',
'/images/combat/sprites/charred-looter-620.png',
]) {
expect(
element.querySelectorAll(`.encounter-card__artwork[src="${sprite}"]`).length,
).toBe(1);
}
expect(element.textContent).toContain('Level 1');
expect(element.textContent).toContain('Level 3');
});
it('marks only the rare encounter, so the player can spot it at a glance', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const rareTags = element.querySelectorAll('[data-encounter-rare]');
expect(rareTags.length).toBe(1);
expect(rareTags[0].textContent?.trim()).toBe('Rare');
expect(element.querySelectorAll('.encounter-card--rare').length).toBe(1);
});
describe('carrying capacity (slice 0.7.5 §12)', () => {
it('shows what the player can still carry beside the encounters', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt, [
{ category: 'HIDE', current: 4, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
]);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-loot-capacities]')).toBeTruthy();
expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain(
'4 / 5',
);
});
it('re-reads capacity on page entry, because the last fight moved it', async () => {
await setup(burnedRoad, threeEncounterHunt);
expect(huntingStore.loadLootCapacities).toHaveBeenCalledOnce();
});
it('renders no strip at all when capacity could not be loaded', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt, []);
const element = fixture.nativeElement as HTMLElement;
// A capacity strip that failed to load is missing decoration, not a
// broken hunt -- it must not become an error banner over the cards.
expect(element.querySelector('[data-loot-capacities]')).toBeNull();
expect(element.querySelectorAll('app-encounter-card').length).toBe(3);
});
});
it('calls refreshHunt when Search Again is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;

View File

@@ -2,12 +2,13 @@ import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { LootCapacityStripComponent } from '../../../shared/loot-capacity-strip/loot-capacity-strip.component';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
@Component({
selector: 'app-hunt-page',
imports: [EncounterCardComponent],
imports: [EncounterCardComponent, LootCapacityStripComponent],
templateUrl: './hunt-page.component.html',
styleUrl: './hunt-page.component.scss',
})
@@ -26,6 +27,9 @@ export class HuntPageComponent implements OnInit {
// including on the way back from a fight -- takes its word over whatever
// roll is still in memory.
void this.huntingStore.loadActiveHunt();
// Same reason: a fight just banked (or refused) loot, so the carrying
// strip has to be re-read rather than trusted from before the fight.
void this.huntingStore.loadLootCapacities();
}
protected startHunt(): void {

View File

@@ -12,15 +12,29 @@ const huntResult: HuntResult = {
encounters: [
{
id: 'encounter-1',
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
monster: {
key: 'wolf',
name: 'Wolf',
level: 1,
artworkPath: '/images/enemies/Wolf.png',
flavorText: null,
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
encounterType: 'NORMAL',
},
{
id: 'encounter-2',
monster: { key: 'bear', name: 'Bear', level: 3, artworkPath: '/images/enemies/Bear.png' },
monster: {
key: 'bear',
name: 'Bear',
level: 3,
artworkPath: '/images/enemies/Bear.png',
flavorText: null,
},
dangerRating: 'STRONG',
status: 'DEFEATED',
encounterType: 'NORMAL',
},
],
};
@@ -31,9 +45,16 @@ const refreshedHuntResult: HuntResult = {
encounters: [
{
id: 'encounter-3',
monster: { key: 'rat', name: 'Rat', level: 1, artworkPath: '/images/enemies/Rat.png' },
monster: {
key: 'rat',
name: 'Rat',
level: 1,
artworkPath: '/images/enemies/Rat.png',
flavorText: null,
},
dangerRating: 'WEAK',
status: 'AVAILABLE',
encounterType: 'NORMAL',
},
],
};

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { HuntResult } from '../../core/api/game-api.models';
import { HuntResult, LootCapacity } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Could not load world state.';
@@ -18,11 +18,13 @@ const HUNT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
@Injectable({ providedIn: 'root' })
export class HuntingStore {
private readonly currentHuntState = signal<HuntResult | null>(null);
private readonly lootCapacitiesState = signal<LootCapacity[]>([]);
private readonly selectedEncounterIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly currentHunt = this.currentHuntState.asReadonly();
readonly lootCapacities = this.lootCapacitiesState.asReadonly();
readonly selectedEncounterId = this.selectedEncounterIdState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
@@ -46,6 +48,23 @@ export class HuntingStore {
}
}
/**
* Refreshes what the character can still carry (slice 0.7.5 §12).
*
* Deliberately silent on failure: a capacity strip that cannot load is
* missing decoration, not a broken hunt, and an error banner over the
* encounter cards would be worse than showing nothing.
*/
async loadLootCapacities(): Promise<void> {
try {
this.lootCapacitiesState.set(
await firstValueFrom(this.api.getLootCapacities()),
);
} catch {
this.lootCapacitiesState.set([]);
}
}
async refreshHunt(): Promise<void> {
await this.startHunt();
}

View File

@@ -0,0 +1,265 @@
@if (store.loading()) {
<p class="merchant__status">Approaching…</p>
} @else if (store.error()) {
<div class="merchant__status merchant__status--error" role="alert">
<p>{{ store.error() }}</p>
<button type="button" class="merchant__button" (click)="leave()">
Back to the gate
</button>
</div>
} @else if (store.interaction(); as interaction) {
<article class="merchant">
<header class="merchant__identity">
<img
class="merchant__portrait"
[src]="interaction.npc.portraitPath"
[alt]="interaction.npc.name"
(error)="($any($event.target).style.visibility = 'hidden')"
/>
<div class="merchant__naming">
<h1 class="merchant__name">{{ interaction.npc.name }}</h1>
@if (interaction.npc.title) {
<p class="merchant__title">{{ interaction.npc.title }}</p>
}
@if (interaction.npc.description) {
<p class="merchant__description">{{ interaction.npc.description }}</p>
}
</div>
</header>
@if (interaction.dialogue) {
<blockquote class="merchant__dialogue" data-dialogue>
{{ interaction.dialogue.text }}
</blockquote>
}
<nav class="merchant__actions" aria-label="Interactions">
@for (action of interaction.availableActions; track action.type) {
<button
type="button"
class="merchant__button"
[class.merchant__button--active]="
(action.type === 'OPEN_EXCHANGE' && isPanel('EXCHANGE')) ||
(action.type === 'OPEN_SHOP' && isPanel('SHOP')) ||
(action.type === 'TALK' && isPanel('DIALOGUE'))
"
[attr.data-action]="action.type"
(click)="activate(action.type, action.key)"
>
{{ action.label }}
</button>
}
<button type="button" class="merchant__button" (click)="leave()">
Leave
</button>
</nav>
@if (store.actionError()) {
<p class="merchant__error" role="alert">{{ store.actionError() }}</p>
}
@if (isPanel('EXCHANGE') && store.exchange(); as exchange) {
<section class="merchant__panel" aria-label="Trade in goods">
<h2 class="merchant__panel-title">{{ exchange.profileName }}</h2>
<app-loot-capacity-strip [capacities]="exchange.capacities" />
@if (exchange.offers.length === 0) {
<p class="merchant__empty">There is nothing here he will take.</p>
} @else {
<ul class="trade-list">
@for (offer of exchange.offers; track offer.itemKey) {
<li
class="trade-row"
[class.trade-row--empty]="offer.quantityCarried === 0"
[attr.data-trade-item]="offer.itemKey"
>
<img
class="trade-row__icon"
[src]="offer.iconPath"
[alt]=""
aria-hidden="true"
/>
<div class="trade-row__naming">
<span class="trade-row__name">{{ offer.itemName }}</span>
<span class="trade-row__carried" data-carried>
Carrying {{ offer.quantityCarried }}
</span>
</div>
<div class="trade-row__value" data-value>
<span class="trade-row__silver"
>{{ offer.silverPerStep }} Silver</span
>
<span class="trade-row__rep"
>+{{ offer.reputationPerStep }}
{{ offer.factionName }}</span
>
</div>
<div class="trade-row__picker">
<button
type="button"
class="trade-row__step"
aria-label="Trade fewer"
[disabled]="
offer.quantityCarried === 0 || store.pending() !== null
"
(click)="step(offer.itemKey, -1, offer.inputQuantity)"
>
</button>
<input
class="trade-row__quantity"
type="number"
min="0"
[max]="offer.quantityCarried"
[step]="offer.inputQuantity"
[value]="quantityFor(offer.itemKey)"
[disabled]="
offer.quantityCarried === 0 || store.pending() !== null
"
[attr.aria-label]="'Quantity of ' + offer.itemName"
(input)="
onQuantityInput(offer.itemKey, $any($event.target).value)
"
/>
<button
type="button"
class="trade-row__step"
aria-label="Trade more"
[disabled]="
offer.quantityCarried === 0 || store.pending() !== null
"
(click)="step(offer.itemKey, 1, offer.inputQuantity)"
>
+
</button>
</div>
</li>
}
</ul>
<footer class="trade-footer">
<p class="trade-footer__preview" data-preview>
Selected:
<strong>{{ store.preview().silver }} Silver</strong>
and
<strong>{{ store.preview().reputation }} Reputation</strong>
</p>
<div class="trade-footer__buttons">
<button
type="button"
class="merchant__button"
[disabled]="store.pending() !== null"
(click)="store.selectAll()"
>
Select All
</button>
<button
type="button"
class="merchant__button merchant__button--primary"
data-trade-confirm
[disabled]="!store.hasSelection() || store.pending() !== null"
(click)="store.tradeSelected()"
>
{{ store.pending() === 'trade' ? 'Handing over…' : 'Trade Selected' }}
</button>
</div>
</footer>
}
@if (store.lastTrade(); as trade) {
<section class="trade-summary" data-trade-summary aria-live="polite">
<h3 class="trade-summary__title">Trade Complete</h3>
<ul class="trade-summary__consumed">
@for (entry of trade.consumed; track entry.itemKey) {
<li>{{ entry.quantity }} × {{ entry.itemName }} handed in</li>
}
</ul>
<ul class="trade-summary__rewards">
<li>+{{ trade.rewards.silver }} Silver</li>
<li>+{{ trade.rewards.regionalReputation }} Border Watch Reputation</li>
@if (trade.rewards.worldRenown > 0) {
<li class="trade-summary__renown" data-renown>
+{{ trade.rewards.worldRenown }} World Renown
</li>
}
</ul>
@if (trade.reputationRankChanged) {
<p class="trade-summary__rank" data-rank-changed>
The Border Watch now regards you differently.
</p>
}
<button
type="button"
class="merchant__button"
(click)="store.dismissTradeSummary()"
>
Done
</button>
</section>
}
</section>
}
@if (isPanel('SHOP') && store.shop(); as shop) {
<section class="merchant__panel" aria-label="Wares for sale">
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
<p class="merchant__purse" data-purse>{{ shop.silver }} Silver</p>
<ul class="shop-list">
@for (offer of shop.offers; track offer.itemKey) {
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
<img
class="shop-row__icon"
[src]="offer.iconPath"
[alt]=""
aria-hidden="true"
/>
<div class="shop-row__naming">
<span class="shop-row__name">{{ offer.itemName }}</span>
<span class="shop-row__description">{{
offer.itemDescription
}}</span>
</div>
<span class="shop-row__price">{{ offer.price }} Silver</span>
<button
type="button"
class="merchant__button"
[disabled]="
!offer.unlocked ||
!offer.affordable ||
store.pending() !== null
"
(click)="store.buy(offer.itemKey)"
>
@if (!offer.unlocked) {
Locked
} @else if (!offer.affordable) {
Too costly
} @else {
Buy
}
</button>
</li>
}
</ul>
@if (store.lastPurchase(); as purchase) {
<p class="shop-receipt" data-purchase aria-live="polite">
Bought {{ purchase.quantity }} × {{ purchase.itemName }} for
{{ purchase.silverSpent }} Silver.
<button
type="button"
class="merchant__link"
(click)="store.dismissPurchase()"
>
Dismiss
</button>
</p>
}
</section>
}
</article>
}

View File

@@ -0,0 +1,381 @@
:host {
display: block;
min-block-size: 0;
}
.merchant {
display: grid;
gap: var(--ar-space-4);
align-content: start;
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-md);
background:
linear-gradient(180deg, rgb(201 164 95 / 0.06), transparent 28%),
var(--ar-panel);
}
.merchant__status {
padding: var(--ar-space-5);
color: var(--ar-text-muted);
}
.merchant__status--error {
display: grid;
justify-items: start;
gap: var(--ar-space-3);
color: var(--ar-danger);
}
/* Portrait first, at a size that reads as a person rather than a list row
(NPC spec §36). */
.merchant__identity {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--ar-space-4);
align-items: start;
}
.merchant__portrait {
inline-size: 7rem;
block-size: 7rem;
object-fit: cover;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted);
}
.merchant__naming {
display: grid;
gap: var(--ar-space-1);
}
.merchant__name {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
color: var(--ar-text);
}
.merchant__title {
margin: 0;
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.merchant__description {
margin: var(--ar-space-2) 0 0;
max-inline-size: 60ch;
color: var(--ar-text-muted);
line-height: 1.55;
}
.merchant__dialogue {
margin: 0;
padding: var(--ar-space-4);
border-inline-start: 2px solid var(--ar-border-highlight);
background: var(--ar-panel-muted);
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.02rem;
line-height: 1.6;
}
.merchant__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-2);
}
.merchant__button {
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel-muted);
color: var(--ar-text);
font: inherit;
font-size: var(--ar-font-sm);
cursor: pointer;
transition: border-color var(--ar-motion-fast), color var(--ar-motion-fast);
}
.merchant__button:hover:not(:disabled) {
border-color: var(--ar-border-highlight);
color: var(--ar-gold);
}
.merchant__button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.merchant__button--active {
border-color: var(--ar-border-highlight);
color: var(--ar-gold);
}
.merchant__button--primary {
border-color: var(--ar-border-highlight);
color: var(--ar-gold);
}
.merchant__link {
border: 0;
background: none;
color: var(--ar-blue);
font: inherit;
font-size: var(--ar-font-sm);
cursor: pointer;
text-decoration: underline;
}
.merchant__error {
margin: 0;
color: var(--ar-danger);
font-size: var(--ar-font-sm);
}
.merchant__panel {
display: grid;
gap: var(--ar-space-3);
padding-block-start: var(--ar-space-4);
border-block-start: 1px solid var(--ar-border);
}
.merchant__panel-title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
color: var(--ar-text);
}
.merchant__purse {
margin: 0;
color: var(--ar-gold);
font-size: var(--ar-font-sm);
}
.merchant__empty {
margin: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.trade-list,
.shop-list {
display: grid;
gap: var(--ar-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.trade-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: var(--ar-space-3);
align-items: center;
padding: var(--ar-space-3);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel-muted);
}
/* Still listed, so the player can see what this merchant would take. */
.trade-row--empty {
opacity: 0.55;
}
.trade-row__icon,
.shop-row__icon {
inline-size: 2.25rem;
block-size: 2.25rem;
object-fit: contain;
}
.trade-row__naming,
.shop-row__naming {
display: grid;
gap: 0.1rem;
min-inline-size: 0;
}
.trade-row__name,
.shop-row__name {
color: var(--ar-text);
}
.trade-row__carried,
.shop-row__description {
color: var(--ar-text-muted);
font-size: 0.75rem;
}
.trade-row__value {
display: grid;
gap: 0.1rem;
justify-items: end;
text-align: end;
}
.trade-row__silver {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
}
.trade-row__rep {
color: var(--ar-text-muted);
font-size: 0.72rem;
}
.trade-row__picker {
display: flex;
align-items: center;
gap: var(--ar-space-1);
}
.trade-row__step {
inline-size: 1.75rem;
block-size: 1.75rem;
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
color: var(--ar-text);
font: inherit;
cursor: pointer;
}
.trade-row__step:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.trade-row__quantity {
inline-size: 3.5rem;
padding: var(--ar-space-1);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
color: var(--ar-text);
font: inherit;
text-align: center;
}
.trade-footer {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
align-items: center;
justify-content: space-between;
padding-block-start: var(--ar-space-2);
}
.trade-footer__preview {
margin: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.trade-footer__preview strong {
color: var(--ar-gold);
font-weight: 600;
}
.trade-footer__buttons {
display: flex;
gap: var(--ar-space-2);
}
/* A quiet ledger, not a reward explosion (slice §10). */
.trade-summary {
display: grid;
gap: var(--ar-space-2);
justify-items: start;
padding: var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel-muted);
}
.trade-summary__title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
color: var(--ar-gold);
}
.trade-summary__consumed,
.trade-summary__rewards {
display: grid;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--ar-font-sm);
}
.trade-summary__consumed {
color: var(--ar-text-muted);
}
.trade-summary__rewards {
color: var(--ar-success);
}
.trade-summary__renown {
color: var(--ar-gold);
font-weight: 600;
}
.trade-summary__rank {
margin: 0;
color: var(--ar-blue);
font-size: var(--ar-font-sm);
}
.shop-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: var(--ar-space-3);
align-items: center;
padding: var(--ar-space-3);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel-muted);
}
.shop-row__price {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
}
.shop-receipt {
display: flex;
gap: var(--ar-space-2);
align-items: center;
margin: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
@media (max-width: 40rem) {
.merchant__identity {
grid-template-columns: 1fr;
}
.trade-row,
.shop-row {
grid-template-columns: auto 1fr;
row-gap: var(--ar-space-2);
}
.trade-row__value,
.trade-row__picker {
grid-column: 1 / -1;
justify-content: start;
justify-items: start;
text-align: start;
}
}

View File

@@ -0,0 +1,311 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { vi } from 'vitest';
import type {
ExchangeResult,
ExchangeView,
NpcInteraction,
ShopView,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { MerchantPageComponent } from './merchant-page.component';
import { MerchantStore } from './merchant.store';
const INTERACTION: NpcInteraction = {
npc: {
id: 'npc-1',
key: 'borin-quartermaster',
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description: 'A broad, grey-bearded man.',
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'],
},
dialogue: {
key: 'borin-default',
text: 'Pelts, hides, raider trinkets — I take all of it.',
responses: [],
},
availableActions: [
{ type: 'TALK', label: 'Talk', key: null },
{ type: 'OPEN_SHOP', label: 'Browse Wares', key: 'borin-supplies' },
{ type: 'OPEN_EXCHANGE', label: 'Trade In Goods', key: 'borin-trade-in' },
],
};
const EXCHANGE: ExchangeView = {
profileKey: 'borin-trade-in',
profileName: 'Border Watch Trade-In',
npcKey: 'borin-quartermaster',
offers: [
{
itemKey: 'ash-pelt',
itemName: 'Ashen Pelt',
iconPath: '/images/items/ash-pelt.png',
quantityCarried: 8,
inputQuantity: 1,
silverPerStep: 5,
reputationPerStep: 2,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: null,
},
{
itemKey: 'charred-raider-insignia',
itemName: 'Charred Raider Insignia',
iconPath: '/images/items/charred.png',
quantityCarried: 0,
inputQuantity: 1,
silverPerStep: 30,
reputationPerStep: 12,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: null,
},
],
capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }],
};
const SHOP: ShopView = {
shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster',
silver: 100,
offers: [
{
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
unlocked: true,
affordable: true,
},
{
itemKey: 'ash-blade',
itemName: 'Ash Blade',
itemDescription: 'Locked for now.',
iconPath: '/images/items/ash-blade.png',
currencyType: 'SILVER',
price: 400,
quantity: 1,
unlocked: false,
affordable: false,
},
],
};
const TRADE_RESULT: ExchangeResult = {
profileKey: 'borin-trade-in',
consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }],
rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 },
balances: { silver: 25, regionalReputation: 10, worldRenown: 2 },
reputationRankChanged: false,
newReputationRank: null,
renownMilestonesCompleted: ['first-goods-returned'],
capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }],
};
async function render(): Promise<{
fixture: ComponentFixture<MerchantPageComponent>;
element: HTMLElement;
store: MerchantStore;
}> {
const api = {
getNpcInteraction: vi.fn(() => of(INTERACTION)),
getTradeIn: vi.fn(() => of(EXCHANGE)),
getShop: vi.fn(() => of(SHOP)),
tradeIn: vi.fn(() => of(TRADE_RESULT)),
purchase: vi.fn(() =>
of({
shopKey: 'borin-supplies',
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
quantity: 1,
silverSpent: 12,
silverBalance: 88,
}),
),
};
await TestBed.configureTestingModule({
imports: [MerchantPageComponent],
providers: [
provideZonelessChangeDetection(),
{ provide: GameApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: { get: () => 'borin-quartermaster' } },
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(MerchantPageComponent);
const store = TestBed.inject(MerchantStore);
// `ngOnInit` kicks off an async load with several awaits in it. A macrotask
// turn drains that whole microtask chain; `whenStable` alone does not under
// zoneless change detection.
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
return { fixture, element: fixture.nativeElement as HTMLElement, store };
}
describe('MerchantPageComponent', () => {
afterEach(() => TestBed.resetTestingModule());
it('presents the NPC as a person, not a table (spec §36)', async () => {
const { element } = await render();
expect(element.querySelector('.merchant__name')?.textContent).toContain(
'Borin',
);
expect(element.querySelector('.merchant__title')?.textContent).toContain(
'Quartermaster',
);
expect(element.querySelector('.merchant__portrait')).not.toBeNull();
expect(element.querySelector('[data-dialogue]')?.textContent).toContain(
'I take all of it',
);
});
it('renders exactly the actions the server offered', async () => {
const { element } = await render();
const actions = Array.from(
element.querySelectorAll('[data-action]'),
).map((node) => node.getAttribute('data-action'));
expect(actions).toEqual(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']);
});
it('shows carried quantity and value per good (slice §10)', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
fixture.detectChanges();
const row = element.querySelector('[data-trade-item="ash-pelt"]');
expect(row?.querySelector('[data-carried]')?.textContent).toContain('8');
expect(row?.querySelector('[data-value]')?.textContent).toContain(
'5 Silver',
);
});
it('still lists a good the player is not carrying, dimmed', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
fixture.detectChanges();
const row = element.querySelector(
'[data-trade-item="charred-raider-insignia"]',
);
expect(row).not.toBeNull();
expect(row?.classList).toContain('trade-row--empty');
});
it('keeps the trade button disabled until something is selected', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
fixture.detectChanges();
const button = element.querySelector<HTMLButtonElement>(
'[data-trade-confirm]',
);
expect(button?.disabled).toBe(true);
store.setQuantity('ash-pelt', 5);
fixture.detectChanges();
expect(
element.querySelector<HTMLButtonElement>('[data-trade-confirm]')?.disabled,
).toBe(false);
});
it('previews the reward before the trade is made', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
store.setQuantity('ash-pelt', 5);
fixture.detectChanges();
const preview = element.querySelector('[data-preview]')?.textContent;
expect(preview).toContain('25 Silver');
expect(preview).toContain('10 Reputation');
});
it('summarises a completed trade as a ledger, including renown', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
store.setQuantity('ash-pelt', 5);
fixture.detectChanges();
await store.tradeSelected();
fixture.detectChanges();
const summary = element.querySelector('[data-trade-summary]');
expect(summary?.textContent).toContain('Trade Complete');
expect(summary?.textContent).toContain('5 × Ashen Pelt');
expect(summary?.textContent).toContain('+25 Silver');
expect(summary?.querySelector('[data-renown]')?.textContent).toContain(
'+1 World Renown',
);
});
it('closes the trade summary when dismissed', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
store.setQuantity('ash-pelt', 5);
fixture.detectChanges();
await store.tradeSelected();
fixture.detectChanges();
expect(element.querySelector('[data-trade-summary]')).not.toBeNull();
store.dismissTradeSummary();
fixture.detectChanges();
expect(element.querySelector('[data-trade-summary]')).toBeNull();
});
it('shows the bag capacity the trade will free (slice §11)', async () => {
const { fixture, element, store } = await render();
store.showPanel('EXCHANGE');
fixture.detectChanges();
expect(element.querySelector('app-loot-capacity-strip')).not.toBeNull();
});
it('disables a locked shop offer and says so', async () => {
const { fixture, element, store } = await render();
store.showPanel('SHOP');
fixture.detectChanges();
const locked = element.querySelector('[data-shop-item="ash-blade"]');
const button = locked?.querySelector('button');
expect(button?.disabled).toBe(true);
expect(button?.textContent).toContain('Locked');
});
it('shows the purse so a price means something', async () => {
const { fixture, element, store } = await render();
store.showPanel('SHOP');
fixture.detectChanges();
expect(element.querySelector('[data-purse]')?.textContent).toContain('100');
});
});

View File

@@ -0,0 +1,82 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { LootCapacityStripComponent } from '../../shared/loot-capacity-strip/loot-capacity-strip.component';
import { WorldStore } from '../world/world.store';
import { MerchantPanel, MerchantStore } from './merchant.store';
/**
* One NPC, presented as a person rather than a form (NPC spec §36).
*
* Portrait, name, title and the line they are currently saying come first;
* trading is something that happens inside that frame. The action bar is
* whatever the server said is available, so a future NPC with no shop simply
* renders one fewer button without a change here.
*/
@Component({
selector: 'app-merchant-page',
imports: [LootCapacityStripComponent],
templateUrl: './merchant-page.component.html',
styleUrl: './merchant-page.component.scss',
})
export class MerchantPageComponent implements OnInit {
protected readonly store = inject(MerchantStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly worldStore = inject(WorldStore);
ngOnInit(): void {
// Opening this screen directly -- a bookmark, a refresh -- means the world
// state was never loaded, and the HUD would sit on "Loading character
// data" for as long as the player stayed here.
if (this.worldStore.character() === null) {
void this.worldStore.load();
}
const npcKey = this.route.snapshot.paramMap.get('npcKey');
if (npcKey) {
void this.store.load(npcKey);
}
}
protected activate(actionType: string, _key: string | null): void {
switch (actionType) {
case 'OPEN_EXCHANGE':
this.store.showPanel('EXCHANGE');
return;
case 'OPEN_SHOP':
this.store.showPanel('SHOP');
return;
case 'TALK':
this.store.showPanel('DIALOGUE');
return;
default:
// VIEW_QUESTS has no screen until Slice 0.9. Ignored rather than
// rendered as a button that does nothing.
return;
}
}
protected isPanel(panel: MerchantPanel): boolean {
return this.store.panel() === panel;
}
protected quantityFor(itemKey: string): number {
return this.store.selection()[itemKey] ?? 0;
}
protected onQuantityInput(itemKey: string, value: string): void {
const parsed = Number.parseInt(value, 10);
this.store.setQuantity(itemKey, Number.isNaN(parsed) ? 0 : parsed);
}
protected step(itemKey: string, direction: 1 | -1, stepSize: number): void {
this.store.setQuantity(
itemKey,
this.quantityFor(itemKey) + direction * stepSize,
);
}
protected leave(): void {
void this.router.navigate(['/location']);
}
}

View File

@@ -0,0 +1,362 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type {
ExchangeResult,
ExchangeView,
NpcInteraction,
ShopView,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { MerchantStore } from './merchant.store';
function interaction(
actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [
'TALK',
'OPEN_SHOP',
'OPEN_EXCHANGE',
],
): NpcInteraction {
return {
npc: {
id: 'npc-1',
key: 'borin-quartermaster',
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description: 'A broad, grey-bearded man.',
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'],
},
dialogue: { key: 'borin-default', text: 'Show me what you have.', responses: [] },
availableActions: actionTypes.map((type) => ({
type,
label: type,
key: type === 'TALK' ? null : 'some-key',
})),
};
}
function exchangeView(overrides: Partial<ExchangeView> = {}): ExchangeView {
return {
profileKey: 'borin-trade-in',
profileName: 'Border Watch Trade-In',
npcKey: 'borin-quartermaster',
offers: [
{
itemKey: 'ash-pelt',
itemName: 'Ashen Pelt',
iconPath: '/images/items/ash-pelt.png',
quantityCarried: 8,
inputQuantity: 1,
silverPerStep: 5,
reputationPerStep: 2,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: 'first-goods-returned',
},
{
itemKey: 'tough-hide',
itemName: 'Tough Hide',
iconPath: '/images/items/tough-hide.png',
quantityCarried: 7,
inputQuantity: 5,
silverPerStep: 40,
reputationPerStep: 10,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: null,
},
],
capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }],
...overrides,
};
}
function shopView(): ShopView {
return {
shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster',
silver: 100,
offers: [
{
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
unlocked: true,
affordable: true,
},
],
};
}
function tradeResult(): ExchangeResult {
return {
profileKey: 'borin-trade-in',
consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }],
rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 },
balances: { silver: 25, regionalReputation: 10, worldRenown: 2 },
reputationRankChanged: false,
newReputationRank: null,
renownMilestonesCompleted: ['first-goods-returned'],
capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }],
};
}
function createApi(overrides: Partial<Record<string, unknown>> = {}) {
return {
getNpcInteraction: vi.fn(() => of(interaction())),
getTradeIn: vi.fn(() => of(exchangeView())),
getShop: vi.fn(() => of(shopView())),
tradeIn: vi.fn(() => of(tradeResult())),
getCharacter: vi.fn(() =>
of({
id: 'character-1',
name: 'Aric Duskwalker',
renown: 2,
silver: 25,
currentHp: 119,
maxHp: 119,
}),
),
purchase: vi.fn(() =>
of({
shopKey: 'borin-supplies',
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
quantity: 1,
silverSpent: 12,
silverBalance: 88,
}),
),
...overrides,
};
}
function createStore(api: ReturnType<typeof createApi>): MerchantStore {
TestBed.configureTestingModule({
providers: [{ provide: GameApiService, useValue: api }],
});
return TestBed.inject(MerchantStore);
}
describe('MerchantStore', () => {
afterEach(() => TestBed.resetTestingModule());
it('loads the NPC and the panels the server offered', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
expect(store.interaction()?.npc.name).toBe('Borin');
expect(store.exchange()?.offers).toHaveLength(2);
expect(store.shop()?.offers).toHaveLength(1);
});
it('does not probe an endpoint the NPC did not offer', async () => {
// A person with no shop should not have their shop fetched. The server
// decides which interactions exist.
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'OPEN_EXCHANGE']))),
});
const store = createStore(api);
await store.load('borin-quartermaster');
expect(api.getShop).not.toHaveBeenCalled();
expect(store.shop()).toBeNull();
});
it('clamps a selection to what is actually carried', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 999);
expect(store.selection()['ash-pelt']).toBe(8);
});
it('rounds a batch rule down to whole steps', async () => {
// Tough Hide trades five at a time and seven are carried, so five is the
// most that can be handed over -- never seven.
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('tough-hide', 7);
expect(store.selection()['tough-hide']).toBe(5);
});
it('never selects a partial batch', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('tough-hide', 4);
expect(store.selection()['tough-hide']).toBe(0);
});
it('refuses a negative quantity', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', -5);
expect(store.selection()['ash-pelt']).toBe(0);
});
it('previews the payout the selection implies', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 4);
store.setQuantity('tough-hide', 5);
// 4 pelts at 5 silver, plus one hide batch at 40.
expect(store.preview()).toEqual({ silver: 60, reputation: 18 });
});
it('selects everything tradeable, in whole steps only', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.selectAll();
expect(store.selection()).toEqual({ 'ash-pelt': 8, 'tough-hide': 5 });
});
it('sends only keys and quantities, then re-reads from the server', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(api.tradeIn).toHaveBeenCalledWith('borin-quartermaster', [
{ itemKey: 'ash-pelt', quantity: 5 },
]);
// Carried goods, capacity and Silver all moved at once, so the view is
// re-fetched rather than patched locally.
expect(api.getTradeIn).toHaveBeenCalledTimes(2);
expect(store.lastTrade()?.rewards.silver).toBe(25);
expect(store.selection()).toEqual({});
expect(store.actionError()).toBeNull();
});
it('pushes the new Silver back to the shared character state', async () => {
// The purse in the top bar reads from `WorldStore`. Without this the
// player sells four pelts and watches their Silver stay put.
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(api.getCharacter).toHaveBeenCalled();
});
it('will not trade with nothing selected', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
await store.tradeSelected();
expect(api.tradeIn).not.toHaveBeenCalled();
});
it('surfaces a rejected trade as a readable message and keeps the selection', async () => {
const api = createApi({
tradeIn: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { code: 'EXCHANGE_INSUFFICIENT_QUANTITY' },
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(store.actionError()).toBe('You are not carrying that many.');
expect(store.lastTrade()).toBeNull();
expect(store.selection()['ash-pelt']).toBe(5);
});
it('falls back to a generic message rather than leaking an unknown code', async () => {
const api = createApi({
tradeIn: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({ status: 500, error: { code: 'WAT' } }),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.actionError()).toBe("That isn't possible right now.");
});
it('reports being unable to reach the NPC', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { code: 'NPC_UNAVAILABLE' },
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
expect(store.error()).toBe('You are not where this person is.');
expect(store.interaction()).toBeNull();
});
it('refreshes the shop after buying, so the purse cannot go stale', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
await store.buy('small-healing-potion');
expect(api.purchase).toHaveBeenCalledWith(
'borin-quartermaster',
'small-healing-potion',
1,
);
expect(api.getShop).toHaveBeenCalledTimes(2);
expect(store.lastPurchase()?.silverSpent).toBe(12);
});
it('starts on the dialogue panel and switches on request', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
expect(store.panel()).toBe('DIALOGUE');
store.showPanel('EXCHANGE');
expect(store.panel()).toBe('EXCHANGE');
});
});

View File

@@ -0,0 +1,264 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
ExchangeResult,
ExchangeView,
NpcInteraction,
ShopPurchaseResult,
ShopView,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR = "That isn't possible right now.";
// Mirrors the codes in the API's npc/exchange/shop error files. Anything not
// listed falls back to the generic line rather than leaking a raw code.
const ERROR_MESSAGES: Readonly<Record<string, string>> = {
NPC_NOT_FOUND: 'This person could not be found.',
NPC_UNAVAILABLE: 'You are not where this person is.',
EXCHANGE_NOT_FOUND: 'This merchant does not trade in goods.',
EXCHANGE_DISABLED: 'This merchant is not trading right now.',
EXCHANGE_ITEM_NOT_ACCEPTED: 'This merchant does not accept that.',
EXCHANGE_INVALID_QUANTITY: 'That quantity cannot be traded.',
EXCHANGE_INSUFFICIENT_QUANTITY: 'You are not carrying that many.',
EXCHANGE_EMPTY_REQUEST: 'Select at least one item to trade.',
SHOP_NOT_FOUND: 'This merchant has nothing to sell.',
SHOP_DISABLED: 'This shop is closed.',
SHOP_OFFER_NOT_FOUND: 'This merchant does not stock that.',
SHOP_OFFER_LOCKED: 'You have not earned the right to buy this yet.',
SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.',
SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.',
CHARACTER_NOT_FOUND: 'Your character could not be found.',
};
export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP';
/**
* State for one merchant screen (Playable Slice 0.8).
*
* Holds the trade-in selection, which is the only genuinely local state here:
* everything about what a good is worth, and whether an offer is open, comes
* from the server on every load. The store never computes a reward — it shows
* the preview the offers imply and lets the server decide the real payout.
*/
@Injectable({ providedIn: 'root' })
export class MerchantStore {
private readonly api = inject(GameApiService);
private readonly worldStore = inject(WorldStore);
private readonly interactionState = signal<NpcInteraction | null>(null);
private readonly exchangeState = signal<ExchangeView | null>(null);
private readonly shopState = signal<ShopView | null>(null);
private readonly panelState = signal<MerchantPanel>('DIALOGUE');
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private readonly actionErrorState = signal<string | null>(null);
private readonly pendingState = signal<string | null>(null);
private readonly lastTradeState = signal<ExchangeResult | null>(null);
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
private readonly selectionState = signal<Record<string, number>>({});
readonly interaction = this.interactionState.asReadonly();
readonly exchange = this.exchangeState.asReadonly();
readonly shop = this.shopState.asReadonly();
readonly panel = this.panelState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
readonly actionError = this.actionErrorState.asReadonly();
readonly pending = this.pendingState.asReadonly();
readonly lastTrade = this.lastTradeState.asReadonly();
readonly lastPurchase = this.lastPurchaseState.asReadonly();
readonly selection = this.selectionState.asReadonly();
/** True once anything is selected, so the trade button can enable. */
readonly hasSelection = computed(() =>
Object.values(this.selectionState()).some((quantity) => quantity > 0),
);
/**
* What the current selection is expected to pay.
*
* A preview, not an authority: the server recomputes it from the same rules
* when the trade is submitted (slice §10 asks for a reward preview).
*/
readonly preview = computed(() => {
const offers = this.exchangeState()?.offers ?? [];
const selection = this.selectionState();
return offers.reduce(
(total, offer) => {
const quantity = selection[offer.itemKey] ?? 0;
if (quantity <= 0) {
return total;
}
const steps = Math.floor(quantity / offer.inputQuantity);
return {
silver: total.silver + steps * offer.silverPerStep,
reputation: total.reputation + steps * offer.reputationPerStep,
};
},
{ silver: 0, reputation: 0 },
);
});
async load(npcKey: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
this.actionErrorState.set(null);
this.lastTradeState.set(null);
this.lastPurchaseState.set(null);
this.selectionState.set({});
this.panelState.set('DIALOGUE');
try {
const interaction = await firstValueFrom(
this.api.getNpcInteraction(npcKey),
);
this.interactionState.set(interaction);
// Only fetch the panels this NPC actually offers. The server decides
// which actions exist, so the client never probes an endpoint it was
// not offered.
const actions = interaction.availableActions.map((action) => action.type);
this.exchangeState.set(
actions.includes('OPEN_EXCHANGE')
? await firstValueFrom(this.api.getTradeIn(npcKey))
: null,
);
this.shopState.set(
actions.includes('OPEN_SHOP')
? await firstValueFrom(this.api.getShop(npcKey))
: null,
);
} catch (error) {
this.interactionState.set(null);
this.exchangeState.set(null);
this.shopState.set(null);
this.errorState.set(this.toMessage(error));
} finally {
this.loadingState.set(false);
}
}
showPanel(panel: MerchantPanel): void {
this.panelState.set(panel);
this.actionErrorState.set(null);
}
/** Clamps to what is carried, so the UI cannot offer an impossible trade. */
setQuantity(itemKey: string, quantity: number): void {
const offer = this.exchangeState()?.offers.find(
(candidate) => candidate.itemKey === itemKey,
);
if (!offer) {
return;
}
// Rounded down to whole tradeable steps: a batch rule that trades five at
// a time must not let four be selected.
const capped = Math.max(0, Math.min(quantity, offer.quantityCarried));
const steps = Math.floor(capped / offer.inputQuantity);
this.selectionState.update((current) => ({
...current,
[itemKey]: steps * offer.inputQuantity,
}));
}
selectAll(): void {
const offers = this.exchangeState()?.offers ?? [];
const selection: Record<string, number> = {};
for (const offer of offers) {
const steps = Math.floor(offer.quantityCarried / offer.inputQuantity);
if (steps > 0) {
selection[offer.itemKey] = steps * offer.inputQuantity;
}
}
this.selectionState.set(selection);
}
clearSelection(): void {
this.selectionState.set({});
}
async tradeSelected(): Promise<void> {
const npcKey = this.interactionState()?.npc.key;
if (!npcKey || this.pendingState() !== null || !this.hasSelection()) {
return;
}
const items = Object.entries(this.selectionState())
.filter(([, quantity]) => quantity > 0)
.map(([itemKey, quantity]) => ({ itemKey, quantity }));
this.pendingState.set('trade');
this.actionErrorState.set(null);
try {
const result = await firstValueFrom(this.api.tradeIn(npcKey, items));
this.lastTradeState.set(result);
this.selectionState.set({});
// Re-read rather than patching locally: the trade changed carried goods,
// capacity, Silver and possibly renown at once, and the server is the
// only place that knows all of it.
this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey)));
this.shopState.set(
this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null,
);
// The purse in the top bar comes from the shared character state, so a
// trade that is not pushed back there leaves the player looking at the
// Silver they had before selling.
await this.worldStore.refreshCharacter();
} catch (error) {
this.actionErrorState.set(this.toMessage(error));
} finally {
this.pendingState.set(null);
}
}
async buy(itemKey: string): Promise<void> {
const npcKey = this.interactionState()?.npc.key;
if (!npcKey || this.pendingState() !== null) {
return;
}
this.pendingState.set(itemKey);
this.actionErrorState.set(null);
try {
this.lastPurchaseState.set(
await firstValueFrom(this.api.purchase(npcKey, itemKey, 1)),
);
this.shopState.set(await firstValueFrom(this.api.getShop(npcKey)));
await this.worldStore.refreshCharacter();
} catch (error) {
this.actionErrorState.set(this.toMessage(error));
} finally {
this.pendingState.set(null);
}
}
dismissTradeSummary(): void {
this.lastTradeState.set(null);
}
dismissPurchase(): void {
this.lastPurchaseState.set(null);
}
private toMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
if (code && ERROR_MESSAGES[code]) {
return ERROR_MESSAGES[code];
}
}
return GENERIC_ERROR;
}
}

View File

@@ -50,11 +50,11 @@ export class LocationPageComponent implements OnInit {
}
protected activatePoi(poi: LocationPointOfInterest): void {
this.dispatch(poi.type, poi.key);
this.dispatch(poi.type, poi.key, poi.npcKey);
}
protected activateAction(action: LocationPrimaryAction): void {
this.dispatch(action.type, action.poiKey ?? action.key);
this.dispatch(action.type, action.poiKey ?? action.key, action.npcKey);
}
protected runtimeArtwork(artworkPath: string): string | undefined {
@@ -75,7 +75,11 @@ export class LocationPageComponent implements OnInit {
* for the screen that owns them; every other type asks the server what
* happened. Unimplemented types are ignored rather than faked.
*/
private dispatch(type: LocationInteractionType, interactionKey: string): void {
private dispatch(
type: LocationInteractionType,
interactionKey: string,
npcKey?: string,
): void {
switch (type) {
case 'HUNT':
void this.router.navigate(['/hunt']);
@@ -87,8 +91,20 @@ export class LocationPageComponent implements OnInit {
case 'INVESTIGATE':
case 'SEARCH':
case 'NPC':
// A hotspot that names an NPC opens that person's screen; one that
// does not is scenery with a line of authored text behind it, and
// still goes through the interaction endpoint.
if (npcKey) {
void this.router.navigate(['/npc', npcKey]);
return;
}
void this.store.runInteraction(interactionKey);
return;
case 'SHOP':
if (npcKey) {
void this.router.navigate(['/npc', npcKey]);
}
return;
default:
return;
}

View File

@@ -5,6 +5,8 @@ import { ItemCardComponent } from './item-card.component';
const banditBlade: RewardItemSummary = {
key: 'bandit-blade',
name: 'Bandit Blade',
type: 'EQUIPMENT',
lootCategory: null,
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
};

View File

@@ -0,0 +1,16 @@
<div class="capacity-strip" data-loot-capacities aria-label="Carrying capacity">
@for (row of rows(); track row.category) {
<p
class="capacity"
[class.capacity--full]="row.full"
[attr.data-loot-capacity]="row.category"
[attr.title]="row.bagName ?? 'No bag carrying by hand'"
>
<span class="capacity__label">{{ row.label }}</span>
<span class="capacity__count">{{ row.current }} / {{ row.capacity }}</span>
@if (row.full) {
<span class="capacity__flag" data-loot-capacity-full>Full</span>
}
</p>
}
</div>

View File

@@ -0,0 +1,45 @@
:host {
display: block;
}
.capacity-strip {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-4);
align-items: center;
}
.capacity {
display: inline-flex;
gap: 0.45rem;
align-items: baseline;
margin: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
}
.capacity__label {
text-transform: uppercase;
}
.capacity__count {
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
/* A full category has to stop the player before the next farm cycle
(spec §12), so it reads as a warning rather than another grey number. */
.capacity--full .capacity__count {
color: var(--ar-danger);
}
.capacity__flag {
padding: 0.05em 0.4em;
border: 1px solid rgb(158 46 42 / 0.75);
border-radius: 0.15rem;
color: #d8736c;
font-size: 0.85em;
text-transform: uppercase;
}

View File

@@ -0,0 +1,89 @@
import { TestBed } from '@angular/core/testing';
import type { LootCapacity } from '../../core/api/game-api.models';
import { LootCapacityStripComponent } from './loot-capacity-strip.component';
function render(capacities: LootCapacity[]): HTMLElement {
const fixture = TestBed.createComponent(LootCapacityStripComponent);
fixture.componentRef.setInput('capacities', capacities);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('LootCapacityStripComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LootCapacityStripComponent],
}).compileComponents();
});
it('shows a readable count per category (spec §12)', () => {
const element = render([
{ category: 'HIDE', current: 4, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 1, capacity: 1, bag: null },
]);
expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain(
'Hides',
);
expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain(
'4 / 5',
);
expect(
element.querySelector('[data-loot-capacity="RAIDER_TROPHY"]')?.textContent,
).toContain('Raider Trophies');
});
it('marks a full category, so the player sees it before the next farm cycle', () => {
const element = render([
{ category: 'HIDE', current: 5, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
]);
const hides = element.querySelector('[data-loot-capacity="HIDE"]');
const trophies = element.querySelector('[data-loot-capacity="RAIDER_TROPHY"]');
expect(hides?.classList).toContain('capacity--full');
expect(hides?.querySelector('[data-loot-capacity-full]')?.textContent).toContain(
'Full',
);
expect(trophies?.classList).not.toContain('capacity--full');
expect(trophies?.querySelector('[data-loot-capacity-full]')).toBeNull();
});
it('treats carrying more than capacity as full, not as room to spare', () => {
// A bag unstowed, or a definition retuned downward, leaves the character
// over the line. That has to read as full rather than as "5 / 3 is fine".
const element = render([{ category: 'HIDE', current: 5, capacity: 3, bag: null }]);
expect(
element.querySelector('[data-loot-capacity="HIDE"]')?.classList,
).toContain('capacity--full');
});
it('names the active bag behind a raised capacity', () => {
const element = render([
{
category: 'HIDE',
current: 0,
capacity: 5,
bag: {
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
iconPath: '/images/items/basic-hide-bag.png',
},
},
]);
expect(
element.querySelector('[data-loot-capacity="HIDE"]')?.getAttribute('title'),
).toBe('Basic Hide Bag');
});
it('explains the bagless default rather than leaving it unlabelled', () => {
const element = render([{ category: 'HIDE', current: 0, capacity: 1, bag: null }]);
expect(
element.querySelector('[data-loot-capacity="HIDE"]')?.getAttribute('title'),
).toBe('No bag — carrying by hand');
});
});

View File

@@ -0,0 +1,48 @@
import { Component, computed, input } from '@angular/core';
import type { LootCapacity, LootCategory } from '../../core/api/game-api.models';
/** Plural names, because the strip counts things (spec §12). */
const CATEGORY_LABELS: Readonly<Record<LootCategory, string>> = {
HIDE: 'Hides',
RAIDER_TROPHY: 'Raider Trophies',
};
interface CapacityRow {
category: LootCategory;
label: string;
current: number;
capacity: number;
full: boolean;
bagName: string | null;
}
/**
* What the character can still carry, per loot category (spec §12).
*
* Deliberately a thin strip rather than a screen: the spec asks for the state
* to be visible while hunting, not for an inventory-management view. Shared by
* the hunt page and the victory summary so both read identically.
*/
@Component({
selector: 'app-loot-capacity-strip',
templateUrl: './loot-capacity-strip.component.html',
styleUrl: './loot-capacity-strip.component.scss',
})
export class LootCapacityStripComponent {
readonly capacities = input.required<LootCapacity[]>();
protected readonly rows = computed<CapacityRow[]>(() =>
this.capacities().map((entry) => ({
category: entry.category,
label: CATEGORY_LABELS[entry.category] ?? entry.category,
current: entry.current,
capacity: entry.capacity,
// `>=` rather than `===`: a character carrying more than capacity (a bag
// unstowed, a definition retuned) is full, not merely at the limit.
full: entry.current >= entry.capacity,
bagName: entry.bag?.name ?? null,
})),
);
protected readonly anyFull = computed(() => this.rows().some((row) => row.full));
}