docs
This commit is contained in:
BIN
apps/web/public/images/items/bandit-insignia.png
Normal file
BIN
apps/web/public/images/items/bandit-insignia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/items/charred-raider-insignia.png
Normal file
BIN
apps/web/public/images/items/charred-raider-insignia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
apps/web/public/images/items/tough-hide.png
Normal file
BIN
apps/web/public/images/items/tough-hide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
265
apps/web/src/app/features/npc/merchant-page.component.html
Normal file
265
apps/web/src/app/features/npc/merchant-page.component.html
Normal 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>
|
||||
}
|
||||
381
apps/web/src/app/features/npc/merchant-page.component.scss
Normal file
381
apps/web/src/app/features/npc/merchant-page.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
311
apps/web/src/app/features/npc/merchant-page.component.spec.ts
Normal file
311
apps/web/src/app/features/npc/merchant-page.component.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
82
apps/web/src/app/features/npc/merchant-page.component.ts
Normal file
82
apps/web/src/app/features/npc/merchant-page.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
362
apps/web/src/app/features/npc/merchant.store.spec.ts
Normal file
362
apps/web/src/app/features/npc/merchant.store.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
264
apps/web/src/app/features/npc/merchant.store.ts
Normal file
264
apps/web/src/app/features/npc/merchant.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user