feat(web): announce offers a trade just unlocked
This commit is contained in:
@@ -208,6 +208,21 @@
|
|||||||
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
|
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
|
||||||
<p class="merchant__purse" data-purse>{{ shop.silver }} Silver</p>
|
<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">
|
<ul class="shop-list">
|
||||||
@for (offer of shop.offers; track offer.itemKey) {
|
@for (offer of shop.offers; track offer.itemKey) {
|
||||||
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
|
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
|
||||||
|
|||||||
@@ -380,6 +380,17 @@
|
|||||||
font-size: var(--ar-font-sm);
|
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) {
|
@media (max-width: 40rem) {
|
||||||
.merchant__identity {
|
.merchant__identity {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ExchangeResult,
|
ExchangeResult,
|
||||||
ExchangeView,
|
ExchangeView,
|
||||||
NpcInteraction,
|
NpcInteraction,
|
||||||
|
ShopOfferView,
|
||||||
ShopView,
|
ShopView,
|
||||||
} from '../../core/api/game-api.models';
|
} from '../../core/api/game-api.models';
|
||||||
import { GameApiService } from '../../core/api/game-api.service';
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
@@ -74,26 +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 {
|
return {
|
||||||
shopKey: 'borin-supplies',
|
shopKey: 'borin-supplies',
|
||||||
shopName: "Quartermaster's Supplies",
|
shopName: "Quartermaster's Supplies",
|
||||||
npcKey: 'borin-quartermaster',
|
npcKey: 'borin-quartermaster',
|
||||||
silver: 100,
|
silver: 100,
|
||||||
offers: [
|
offers: offers ?? [
|
||||||
{
|
offer('small-healing-potion', 'Small Healing Potion', true),
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,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 {
|
return {
|
||||||
getNpcInteraction: vi.fn(() => of(interaction())),
|
getNpcInteraction: vi.fn(() => of(interaction())),
|
||||||
getTradeIn: vi.fn(() => of(exchangeView())),
|
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())),
|
tradeIn: vi.fn(() => of(tradeResult())),
|
||||||
getCharacter: vi.fn(() =>
|
getCharacter: vi.fn(() =>
|
||||||
of({
|
of({
|
||||||
@@ -137,7 +159,7 @@ function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
|||||||
silverBalance: 88,
|
silverBalance: 88,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...overrides,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,4 +383,75 @@ describe('MerchantStore', () => {
|
|||||||
store.showPanel('EXCHANGE');
|
store.showPanel('EXCHANGE');
|
||||||
expect(store.panel()).toBe('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_OFFER_LOCKED: 'You have not earned the right to buy this yet.',
|
||||||
SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.',
|
SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.',
|
||||||
SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.',
|
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.',
|
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 lastTradeState = signal<ExchangeResult | null>(null);
|
||||||
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
|
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
|
||||||
private readonly selectionState = signal<Record<string, number>>({});
|
private readonly selectionState = signal<Record<string, number>>({});
|
||||||
|
private readonly newlyUnlockedState = signal<string[]>([]);
|
||||||
|
|
||||||
readonly interaction = this.interactionState.asReadonly();
|
readonly interaction = this.interactionState.asReadonly();
|
||||||
readonly exchange = this.exchangeState.asReadonly();
|
readonly exchange = this.exchangeState.asReadonly();
|
||||||
@@ -71,6 +74,7 @@ export class MerchantStore {
|
|||||||
readonly lastTrade = this.lastTradeState.asReadonly();
|
readonly lastTrade = this.lastTradeState.asReadonly();
|
||||||
readonly lastPurchase = this.lastPurchaseState.asReadonly();
|
readonly lastPurchase = this.lastPurchaseState.asReadonly();
|
||||||
readonly selection = this.selectionState.asReadonly();
|
readonly selection = this.selectionState.asReadonly();
|
||||||
|
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
|
||||||
|
|
||||||
/** True once anything is selected, so the trade button can enable. */
|
/** True once anything is selected, so the trade button can enable. */
|
||||||
readonly hasSelection = computed(() =>
|
readonly hasSelection = computed(() =>
|
||||||
@@ -110,6 +114,7 @@ export class MerchantStore {
|
|||||||
this.lastTradeState.set(null);
|
this.lastTradeState.set(null);
|
||||||
this.lastPurchaseState.set(null);
|
this.lastPurchaseState.set(null);
|
||||||
this.selectionState.set({});
|
this.selectionState.set({});
|
||||||
|
this.newlyUnlockedState.set([]);
|
||||||
this.panelState.set('DIALOGUE');
|
this.panelState.set('DIALOGUE');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -207,8 +212,13 @@ export class MerchantStore {
|
|||||||
// capacity, Silver and possibly renown at once, and the server is the
|
// capacity, Silver and possibly renown at once, and the server is the
|
||||||
// only place that knows all of it.
|
// only place that knows all of it.
|
||||||
this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey)));
|
this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey)));
|
||||||
this.shopState.set(
|
const previousShop = this.shopState();
|
||||||
this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null,
|
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
|
// 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.pendingState.set(itemKey);
|
||||||
this.actionErrorState.set(null);
|
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 {
|
try {
|
||||||
this.lastPurchaseState.set(
|
this.lastPurchaseState.set(
|
||||||
@@ -252,6 +265,37 @@ export class MerchantStore {
|
|||||||
this.lastPurchaseState.set(null);
|
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 {
|
private toMessage(error: unknown): string {
|
||||||
if (error instanceof HttpErrorResponse) {
|
if (error instanceof HttpErrorResponse) {
|
||||||
const code = (error.error as { code?: string } | null)?.code;
|
const code = (error.error as { code?: string } | null)?.code;
|
||||||
|
|||||||
Reference in New Issue
Block a user