diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html
index 9850ecd..cad6e7a 100644
--- a/apps/web/src/app/features/npc/merchant-page.component.html
+++ b/apps/web/src/app/features/npc/merchant-page.component.html
@@ -208,6 +208,21 @@
{{ shop.shopName }}
{{ shop.silver }} Silver
+ @if (store.newlyUnlocked().length > 0) {
+
+ @for (name of store.newlyUnlocked(); track name) {
+ New merchant offer unlocked: {{ name }}
+ }
+
+
+ }
+
@for (offer of shop.offers; track offer.itemKey) {
-
diff --git a/apps/web/src/app/features/npc/merchant-page.component.scss b/apps/web/src/app/features/npc/merchant-page.component.scss
index e21acf0..9e6f792 100644
--- a/apps/web/src/app/features/npc/merchant-page.component.scss
+++ b/apps/web/src/app/features/npc/merchant-page.component.scss
@@ -380,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;
diff --git a/apps/web/src/app/features/npc/merchant.store.spec.ts b/apps/web/src/app/features/npc/merchant.store.spec.ts
index 09fa08d..8e22aaf 100644
--- a/apps/web/src/app/features/npc/merchant.store.spec.ts
+++ b/apps/web/src/app/features/npc/merchant.store.spec.ts
@@ -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,26 +75,35 @@ function exchangeView(overrides: Partial = {}): 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,
- effectSummary: null,
- requirements: [],
- unlocked: true,
- affordable: true,
- },
+ offers: offers ?? [
+ offer('small-healing-potion', 'Small Healing Potion', true),
],
};
}
@@ -111,11 +121,23 @@ function tradeResult(): ExchangeResult {
};
}
-function createApi(overrides: Partial> = {}) {
+function createApi(
+ overrides: Partial> & { 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({
@@ -137,7 +159,7 @@ function createApi(overrides: Partial> = {}) {
silverBalance: 88,
}),
),
- ...overrides,
+ ...rest,
};
}
@@ -361,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([]);
+ });
});
diff --git a/apps/web/src/app/features/npc/merchant.store.ts b/apps/web/src/app/features/npc/merchant.store.ts
index ee3c2e3..77be705 100644
--- a/apps/web/src/app/features/npc/merchant.store.ts
+++ b/apps/web/src/app/features/npc/merchant.store.ts
@@ -30,6 +30,8 @@ const ERROR_MESSAGES: Readonly> = {
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(null);
private readonly lastPurchaseState = signal(null);
private readonly selectionState = signal>({});
+ private readonly newlyUnlockedState = signal([]);
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;