feat(web): show offer requirements, current progress and effects

This commit is contained in:
Bastian Wagner
2026-08-22 20:15:51 +02:00
parent 36adc32659
commit 00784094ab
4 changed files with 262 additions and 0 deletions

View File

@@ -430,6 +430,14 @@ export interface ExchangeResult {
capacities: LootCapacity[];
}
/** One gate on an offer, as the server phrased it (slice 0.8.5 §5, §8). */
export interface ShopOfferRequirement {
label: string;
current: number | null;
required: number | null;
met: boolean;
}
export interface ShopOfferView {
itemKey: string;
itemName: string;
@@ -438,6 +446,13 @@ export interface ShopOfferView {
currencyType: string;
price: number;
quantity: number;
/**
* Optional so fixtures written before slice 0.8.5 (e.g. the trade-in store's
* own spec, out of this task's scope) keep compiling untouched. The server
* always sends both fields; the template treats a missing one as "none".
*/
effectSummary?: string | null;
requirements?: ShopOfferRequirement[];
unlocked: boolean;
affordable: boolean;
}

View File

@@ -222,6 +222,28 @@
<span class="shop-row__description">{{
offer.itemDescription
}}</span>
@if (offer.effectSummary) {
<span class="shop-row__effect" data-effect>{{
offer.effectSummary
}}</span>
}
@for (
requirement of offer.requirements ?? [];
track requirement.label
) {
<span
class="shop-row__requirement"
[class.shop-row__requirement--met]="requirement.met"
data-requirement
>
{{ requirement.label }}
@if (!requirement.met && requirement.current !== null) {
<span class="shop-row__current"
>· Current: {{ requirement.current }}</span
>
}
</span>
}
</div>
<span class="shop-row__price">{{ offer.price }} Silver</span>
<button

View File

@@ -351,6 +351,26 @@
font-size: var(--ar-font-sm);
}
.shop-row__effect {
color: var(--ar-text);
font-size: 0.75rem;
}
/* A lock the player can measure themselves against, not a dead end
(slice 0.8.5 §5). */
.shop-row__requirement {
color: var(--ar-gold);
font-size: 0.75rem;
}
.shop-row__requirement--met {
color: var(--ar-text-muted);
}
.shop-row__current {
color: var(--ar-text-muted);
}
.shop-receipt {
display: flex;
gap: var(--ar-space-2);

View File

@@ -83,6 +83,8 @@ const SHOP: ShopView = {
currencyType: 'SILVER',
price: 12,
quantity: 1,
effectSummary: null,
requirements: [],
unlocked: true,
affordable: true,
},
@@ -94,6 +96,8 @@ const SHOP: ShopView = {
currencyType: 'SILVER',
price: 400,
quantity: 1,
effectSummary: null,
requirements: [],
unlocked: false,
affordable: false,
},
@@ -160,6 +164,74 @@ async function render(): Promise<{
return { fixture, element: fixture.nativeElement as HTMLElement, store };
}
/**
* Renders the merchant page with a caller-supplied shop and opens the shop
* panel, so requirement/effect tests don't need to repeat the NPC and
* exchange fixtures they don't care about.
*/
async function renderMerchantWithShop(
shopOverrides: Partial<ShopView>,
): Promise<{
fixture: ComponentFixture<MerchantPageComponent>;
element: HTMLElement;
store: MerchantStore;
}> {
const shop: ShopView = {
shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster',
silver: 100,
offers: [],
...shopOverrides,
};
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);
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
element
.querySelector<HTMLButtonElement>('[data-action="OPEN_SHOP"]')
?.click();
fixture.detectChanges();
return { fixture, element, store };
}
describe('MerchantPageComponent', () => {
afterEach(() => TestBed.resetTestingModule());
@@ -308,4 +380,137 @@ describe('MerchantPageComponent', () => {
expect(element.querySelector('[data-purse]')?.textContent).toContain('100');
});
it('shows the requirement and the current value on a locked offer', async () => {
// A visible lock with a number attached is a goal; a hidden offer is not
// (slice §5).
const { fixture } = await renderMerchantWithShop({
offers: [
{
itemKey: 'basic-trophy-pouch',
itemName: 'Basic Trophy Pouch',
itemDescription: 'Capacity: 5 Raider Trophies',
iconPath: '/images/items/basic-trophy-pouch.png',
currencyType: 'SILVER',
price: 40,
quantity: 1,
effectSummary: 'Capacity: 5 Raider Trophies',
requirements: [
{
label: 'Requires Border Watch Reputation 25',
current: 14,
required: 25,
met: false,
},
],
unlocked: false,
affordable: true,
},
],
});
const row = fixture.nativeElement.querySelector(
'[data-shop-item="basic-trophy-pouch"]',
);
expect(row.textContent).toContain('Requires Border Watch Reputation 25');
expect(row.textContent).toContain('Current: 14');
expect(row.querySelector('button').disabled).toBe(true);
});
it('shows what a purchase does', async () => {
const { fixture } = await renderMerchantWithShop({
offers: [
{
itemKey: 'bandit-blade',
itemName: 'Bandit Blade',
itemDescription: 'A roughly serrated blade, forged for quick raids.',
iconPath: '/images/items/bandit-blade.png',
currencyType: 'SILVER',
price: 60,
quantity: 1,
effectSummary: '11 Weapon Damage, +1 Attack',
requirements: [
{
label: 'Requires World Renown 3',
current: 1,
required: 3,
met: false,
},
],
unlocked: false,
affordable: true,
},
],
});
const row = fixture.nativeElement.querySelector(
'[data-shop-item="bandit-blade"]',
);
expect(row.textContent).toContain('11 Weapon Damage, +1 Attack');
});
it('does not label an open offer as requiring anything unmet', async () => {
const { fixture } = await renderMerchantWithShop({
offers: [
{
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
effectSummary: null,
requirements: [],
unlocked: true,
affordable: true,
},
],
});
const row = fixture.nativeElement.querySelector(
'[data-shop-item="small-healing-potion"]',
);
expect(row.querySelector('[data-requirement]')).toBeNull();
expect(row.querySelector('button').disabled).toBe(false);
});
it('renders a bypassed offer as buyable even with an unmet requirement shown', async () => {
// A later slice opens an offer through a quest bypass rather than by
// meeting the stated requirement. The server still describes the
// requirement it evaluated, but `unlocked` is what actually gates the
// button -- an unmet requirement must not re-lock a bypassed offer.
const { fixture } = await renderMerchantWithShop({
offers: [
{
itemKey: 'referred-trinket',
itemName: 'Referred Trinket',
itemDescription: 'A favour, called in.',
iconPath: '/images/items/referred-trinket.png',
currencyType: 'SILVER',
price: 20,
quantity: 1,
effectSummary: null,
requirements: [
{
label: 'Requires Border Watch Reputation 25',
current: 14,
required: 25,
met: false,
},
],
unlocked: true,
affordable: true,
},
],
});
const row = fixture.nativeElement.querySelector(
'[data-shop-item="referred-trinket"]',
);
expect(row.textContent).toContain('Requires Border Watch Reputation 25');
const button = row.querySelector('button');
expect(button.disabled).toBe(false);
expect(button.textContent).toContain('Buy');
});
});