feat(web): show offer requirements, current progress and effects
This commit is contained in:
@@ -430,6 +430,14 @@ export interface ExchangeResult {
|
|||||||
capacities: LootCapacity[];
|
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 {
|
export interface ShopOfferView {
|
||||||
itemKey: string;
|
itemKey: string;
|
||||||
itemName: string;
|
itemName: string;
|
||||||
@@ -438,6 +446,13 @@ export interface ShopOfferView {
|
|||||||
currencyType: string;
|
currencyType: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: 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;
|
unlocked: boolean;
|
||||||
affordable: boolean;
|
affordable: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,28 @@
|
|||||||
<span class="shop-row__description">{{
|
<span class="shop-row__description">{{
|
||||||
offer.itemDescription
|
offer.itemDescription
|
||||||
}}</span>
|
}}</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>
|
</div>
|
||||||
<span class="shop-row__price">{{ offer.price }} Silver</span>
|
<span class="shop-row__price">{{ offer.price }} Silver</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -351,6 +351,26 @@
|
|||||||
font-size: var(--ar-font-sm);
|
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 {
|
.shop-receipt {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--ar-space-2);
|
gap: var(--ar-space-2);
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const SHOP: ShopView = {
|
|||||||
currencyType: 'SILVER',
|
currencyType: 'SILVER',
|
||||||
price: 12,
|
price: 12,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
effectSummary: null,
|
||||||
|
requirements: [],
|
||||||
unlocked: true,
|
unlocked: true,
|
||||||
affordable: true,
|
affordable: true,
|
||||||
},
|
},
|
||||||
@@ -94,6 +96,8 @@ const SHOP: ShopView = {
|
|||||||
currencyType: 'SILVER',
|
currencyType: 'SILVER',
|
||||||
price: 400,
|
price: 400,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
effectSummary: null,
|
||||||
|
requirements: [],
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
affordable: false,
|
affordable: false,
|
||||||
},
|
},
|
||||||
@@ -160,6 +164,74 @@ async function render(): Promise<{
|
|||||||
return { fixture, element: fixture.nativeElement as HTMLElement, store };
|
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', () => {
|
describe('MerchantPageComponent', () => {
|
||||||
afterEach(() => TestBed.resetTestingModule());
|
afterEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
@@ -308,4 +380,137 @@ describe('MerchantPageComponent', () => {
|
|||||||
|
|
||||||
expect(element.querySelector('[data-purse]')?.textContent).toContain('100');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user