Merge branch 'slice/0.8.5-reputation-gated-merchant-offers'
This commit is contained in:
@@ -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,8 @@ export interface ShopOfferView {
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
effectSummary: string | null;
|
||||
requirements: ShopOfferRequirement[];
|
||||
unlocked: boolean;
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,21 @@
|
||||
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
|
||||
<p class="merchant__purse" data-purse>{{ shop.silver }} Silver</p>
|
||||
|
||||
@if (store.newlyUnlocked().length > 0) {
|
||||
<p class="shop-unlocked" data-unlocked aria-live="polite">
|
||||
@for (name of store.newlyUnlocked(); track name) {
|
||||
<span>New merchant offer unlocked: {{ name }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="merchant__link"
|
||||
(click)="store.dismissUnlocked()"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
<ul class="shop-list">
|
||||
@for (offer of shop.offers; track offer.itemKey) {
|
||||
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
|
||||
@@ -219,9 +234,33 @@
|
||||
/>
|
||||
<div class="shop-row__naming">
|
||||
<span class="shop-row__name">{{ offer.itemName }}</span>
|
||||
<span class="shop-row__description">{{
|
||||
offer.itemDescription
|
||||
}}</span>
|
||||
@if (offer.itemDescription) {
|
||||
<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
|
||||
|
||||
@@ -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);
|
||||
@@ -360,6 +380,17 @@
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
/* A line, not a modal (slice 0.8.5 §9). */
|
||||
.shop-unlocked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ar-space-2);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: var(--ar-gold);
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.merchant__identity {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -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,79 @@ 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 };
|
||||
}
|
||||
|
||||
/** How many times `needle` shows up in `haystack` -- `toContain` cannot say. */
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
describe('MerchantPageComponent', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
@@ -308,4 +385,147 @@ 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). Shaped like the real Trophy Pouch offer: a bag definition
|
||||
// carries no flavour text, so the API sends an empty description and the
|
||||
// capacity line arrives once, as the effect.
|
||||
const { fixture } = await renderMerchantWithShop({
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'basic-trophy-pouch',
|
||||
itemName: 'Basic Trophy Pouch',
|
||||
itemDescription: '',
|
||||
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');
|
||||
// Once, not twice: slice §5's worked example shows the capacity line a
|
||||
// single time.
|
||||
expect(
|
||||
countOccurrences(row.textContent, 'Capacity: 5 Raider Trophies'),
|
||||
).toBe(1);
|
||||
// An empty description renders nothing at all, rather than an empty span
|
||||
// that would let a duplicated capacity line back in unnoticed.
|
||||
expect(row.querySelector('.shop-row__description')).toBeNull();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ExchangeResult,
|
||||
ExchangeView,
|
||||
NpcInteraction,
|
||||
ShopOfferView,
|
||||
ShopView,
|
||||
} from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
@@ -74,24 +75,35 @@ function exchangeView(overrides: Partial<ExchangeView> = {}): ExchangeView {
|
||||
};
|
||||
}
|
||||
|
||||
function shopView(): ShopView {
|
||||
/** A single shop offer, defaulting to the fields no test in this file varies. */
|
||||
function offer(
|
||||
itemKey: string,
|
||||
itemName: string,
|
||||
unlocked: boolean,
|
||||
): ShopOfferView {
|
||||
return {
|
||||
itemKey,
|
||||
itemName,
|
||||
itemDescription: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
effectSummary: null,
|
||||
requirements: [],
|
||||
unlocked,
|
||||
affordable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function shopView(offers?: ShopOfferView[]): 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,
|
||||
},
|
||||
offers: offers ?? [
|
||||
offer('small-healing-potion', 'Small Healing Potion', true),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -109,11 +121,23 @@ function tradeResult(): ExchangeResult {
|
||||
};
|
||||
}
|
||||
|
||||
function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
function createApi(
|
||||
overrides: Partial<Record<string, unknown>> & { shopSequence?: ShopView[] } = {},
|
||||
) {
|
||||
// `shopSequence` lets a test hand back a different shop view on each call to
|
||||
// `getShop`, so before/after `unlocked` flags can be observed across a trade
|
||||
// without needing a real server round-trip.
|
||||
const { shopSequence, ...rest } = overrides;
|
||||
let shopCallIndex = 0;
|
||||
|
||||
return {
|
||||
getNpcInteraction: vi.fn(() => of(interaction())),
|
||||
getTradeIn: vi.fn(() => of(exchangeView())),
|
||||
getShop: vi.fn(() => of(shopView())),
|
||||
getShop: vi.fn(() =>
|
||||
shopSequence
|
||||
? of(shopSequence[Math.min(shopCallIndex++, shopSequence.length - 1)])
|
||||
: of(shopView()),
|
||||
),
|
||||
tradeIn: vi.fn(() => of(tradeResult())),
|
||||
getCharacter: vi.fn(() =>
|
||||
of({
|
||||
@@ -135,7 +159,7 @@ function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
silverBalance: 88,
|
||||
}),
|
||||
),
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,4 +383,75 @@ describe('MerchantStore', () => {
|
||||
store.showPanel('EXCHANGE');
|
||||
expect(store.panel()).toBe('EXCHANGE');
|
||||
});
|
||||
|
||||
it('announces an offer that a trade just unlocked', async () => {
|
||||
// Reputation earned by the trade opened the pouch. The player should learn
|
||||
// that without hunting for it (slice §9).
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
|
||||
});
|
||||
|
||||
it('says nothing when a trade unlocks nothing', async () => {
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not re-announce an offer that was already open', async () => {
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
|
||||
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears the unlock banner on a fresh load and at the start of a purchase', async () => {
|
||||
// A stale banner from a previous merchant visit, or from before a buy
|
||||
// click resolves, would misattribute an unlock to the wrong action.
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
|
||||
|
||||
await store.buy('basic-trophy-pouch');
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
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.',
|
||||
MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.',
|
||||
SHOP_BAG_ALREADY_OWNED: 'You already carry that.',
|
||||
CHARACTER_NOT_FOUND: 'Your character could not be found.',
|
||||
};
|
||||
|
||||
@@ -59,6 +61,7 @@ export class MerchantStore {
|
||||
private readonly lastTradeState = signal<ExchangeResult | null>(null);
|
||||
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
|
||||
private readonly selectionState = signal<Record<string, number>>({});
|
||||
private readonly newlyUnlockedState = signal<string[]>([]);
|
||||
|
||||
readonly interaction = this.interactionState.asReadonly();
|
||||
readonly exchange = this.exchangeState.asReadonly();
|
||||
@@ -71,6 +74,7 @@ export class MerchantStore {
|
||||
readonly lastTrade = this.lastTradeState.asReadonly();
|
||||
readonly lastPurchase = this.lastPurchaseState.asReadonly();
|
||||
readonly selection = this.selectionState.asReadonly();
|
||||
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
|
||||
|
||||
/** True once anything is selected, so the trade button can enable. */
|
||||
readonly hasSelection = computed(() =>
|
||||
@@ -110,6 +114,7 @@ export class MerchantStore {
|
||||
this.lastTradeState.set(null);
|
||||
this.lastPurchaseState.set(null);
|
||||
this.selectionState.set({});
|
||||
this.newlyUnlockedState.set([]);
|
||||
this.panelState.set('DIALOGUE');
|
||||
|
||||
try {
|
||||
@@ -207,8 +212,13 @@ export class MerchantStore {
|
||||
// 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,
|
||||
const previousShop = this.shopState();
|
||||
const refreshedShop = previousShop
|
||||
? await firstValueFrom(this.api.getShop(npcKey))
|
||||
: null;
|
||||
this.shopState.set(refreshedShop);
|
||||
this.newlyUnlockedState.set(
|
||||
this.unlockedSince(previousShop, refreshedShop),
|
||||
);
|
||||
|
||||
// The purse in the top bar comes from the shared character state, so a
|
||||
@@ -230,6 +240,9 @@ export class MerchantStore {
|
||||
|
||||
this.pendingState.set(itemKey);
|
||||
this.actionErrorState.set(null);
|
||||
// A purchase re-reads the shop below, so a banner from an earlier trade
|
||||
// must not linger and be misread as caused by this buy.
|
||||
this.newlyUnlockedState.set([]);
|
||||
|
||||
try {
|
||||
this.lastPurchaseState.set(
|
||||
@@ -252,6 +265,37 @@ export class MerchantStore {
|
||||
this.lastPurchaseState.set(null);
|
||||
}
|
||||
|
||||
dismissUnlocked(): void {
|
||||
this.newlyUnlockedState.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer names that went from locked to open (slice §9).
|
||||
*
|
||||
* Derived from two shop reads the store already performs rather than from a
|
||||
* server event: the trade re-fetches the shop anyway, so the before/after
|
||||
* state is in hand, and a notification the server has to remember would be
|
||||
* more machinery than a one-line message is worth.
|
||||
*/
|
||||
private unlockedSince(
|
||||
before: ShopView | null,
|
||||
after: ShopView | null,
|
||||
): string[] {
|
||||
if (!before || !after) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const wasLocked = new Set(
|
||||
before.offers
|
||||
.filter((offer) => !offer.unlocked)
|
||||
.map((offer) => offer.itemKey),
|
||||
);
|
||||
|
||||
return after.offers
|
||||
.filter((offer) => offer.unlocked && wasLocked.has(offer.itemKey))
|
||||
.map((offer) => offer.itemName);
|
||||
}
|
||||
|
||||
private toMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const code = (error.error as { code?: string } | null)?.code;
|
||||
|
||||
Reference in New Issue
Block a user