import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { ExchangeResult, ExchangeView, NpcInteraction, ShopPurchaseResult, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; import { WorldStore } from '../world/world.store'; const GENERIC_ERROR = "That isn't possible right now."; // Mirrors the codes in the API's npc/exchange/shop error files. Anything not // listed falls back to the generic line rather than leaking a raw code. const ERROR_MESSAGES: Readonly> = { NPC_NOT_FOUND: 'This person could not be found.', NPC_UNAVAILABLE: 'You are not where this person is.', EXCHANGE_NOT_FOUND: 'This merchant does not trade in goods.', EXCHANGE_DISABLED: 'This merchant is not trading right now.', EXCHANGE_ITEM_NOT_ACCEPTED: 'This merchant does not accept that.', EXCHANGE_INVALID_QUANTITY: 'That quantity cannot be traded.', EXCHANGE_INSUFFICIENT_QUANTITY: 'You are not carrying that many.', EXCHANGE_EMPTY_REQUEST: 'Select at least one item to trade.', SHOP_NOT_FOUND: 'This merchant has nothing to sell.', SHOP_DISABLED: 'This shop is closed.', SHOP_OFFER_NOT_FOUND: 'This merchant does not stock that.', 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.', }; export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; /** * State for one merchant screen (Playable Slice 0.8). * * Holds the trade-in selection, which is the only genuinely local state here: * everything about what a good is worth, and whether an offer is open, comes * from the server on every load. The store never computes a reward — it shows * the preview the offers imply and lets the server decide the real payout. */ @Injectable({ providedIn: 'root' }) export class MerchantStore { private readonly api = inject(GameApiService); private readonly worldStore = inject(WorldStore); private readonly interactionState = signal(null); private readonly exchangeState = signal(null); private readonly shopState = signal(null); private readonly panelState = signal('DIALOGUE'); private readonly loadingState = signal(false); private readonly errorState = signal(null); private readonly actionErrorState = signal(null); private readonly pendingState = signal(null); 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(); readonly shop = this.shopState.asReadonly(); readonly panel = this.panelState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); readonly actionError = this.actionErrorState.asReadonly(); readonly pending = this.pendingState.asReadonly(); 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(() => Object.values(this.selectionState()).some((quantity) => quantity > 0), ); /** * What the current selection is expected to pay. * * A preview, not an authority: the server recomputes it from the same rules * when the trade is submitted (slice §10 asks for a reward preview). */ readonly preview = computed(() => { const offers = this.exchangeState()?.offers ?? []; const selection = this.selectionState(); return offers.reduce( (total, offer) => { const quantity = selection[offer.itemKey] ?? 0; if (quantity <= 0) { return total; } const steps = Math.floor(quantity / offer.inputQuantity); return { silver: total.silver + steps * offer.silverPerStep, reputation: total.reputation + steps * offer.reputationPerStep, }; }, { silver: 0, reputation: 0 }, ); }); async load(npcKey: string): Promise { this.loadingState.set(true); this.errorState.set(null); this.actionErrorState.set(null); this.lastTradeState.set(null); this.lastPurchaseState.set(null); this.selectionState.set({}); this.newlyUnlockedState.set([]); this.panelState.set('DIALOGUE'); try { const interaction = await firstValueFrom( this.api.getNpcInteraction(npcKey), ); this.interactionState.set(interaction); // Only fetch the panels this NPC actually offers. The server decides // which actions exist, so the client never probes an endpoint it was // not offered. const actions = interaction.availableActions.map((action) => action.type); this.exchangeState.set( actions.includes('OPEN_EXCHANGE') ? await firstValueFrom(this.api.getTradeIn(npcKey)) : null, ); this.shopState.set( actions.includes('OPEN_SHOP') ? await firstValueFrom(this.api.getShop(npcKey)) : null, ); } catch (error) { this.interactionState.set(null); this.exchangeState.set(null); this.shopState.set(null); this.errorState.set(this.toMessage(error)); } finally { this.loadingState.set(false); } } showPanel(panel: MerchantPanel): void { this.panelState.set(panel); this.actionErrorState.set(null); } /** Clamps to what is carried, so the UI cannot offer an impossible trade. */ setQuantity(itemKey: string, quantity: number): void { const offer = this.exchangeState()?.offers.find( (candidate) => candidate.itemKey === itemKey, ); if (!offer) { return; } // Rounded down to whole tradeable steps: a batch rule that trades five at // a time must not let four be selected. const capped = Math.max(0, Math.min(quantity, offer.quantityCarried)); const steps = Math.floor(capped / offer.inputQuantity); this.selectionState.update((current) => ({ ...current, [itemKey]: steps * offer.inputQuantity, })); } selectAll(): void { const offers = this.exchangeState()?.offers ?? []; const selection: Record = {}; for (const offer of offers) { const steps = Math.floor(offer.quantityCarried / offer.inputQuantity); if (steps > 0) { selection[offer.itemKey] = steps * offer.inputQuantity; } } this.selectionState.set(selection); } clearSelection(): void { this.selectionState.set({}); } async tradeSelected(): Promise { const npcKey = this.interactionState()?.npc.key; if (!npcKey || this.pendingState() !== null || !this.hasSelection()) { return; } const items = Object.entries(this.selectionState()) .filter(([, quantity]) => quantity > 0) .map(([itemKey, quantity]) => ({ itemKey, quantity })); this.pendingState.set('trade'); this.actionErrorState.set(null); try { const result = await firstValueFrom(this.api.tradeIn(npcKey, items)); this.lastTradeState.set(result); this.selectionState.set({}); // Re-read rather than patching locally: the trade changed carried goods, // 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))); 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 // trade that is not pushed back there leaves the player looking at the // Silver they had before selling. await this.worldStore.refreshCharacter(); } catch (error) { this.actionErrorState.set(this.toMessage(error)); } finally { this.pendingState.set(null); } } async buy(itemKey: string): Promise { const npcKey = this.interactionState()?.npc.key; if (!npcKey || this.pendingState() !== null) { return; } 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( await firstValueFrom(this.api.purchase(npcKey, itemKey, 1)), ); this.shopState.set(await firstValueFrom(this.api.getShop(npcKey))); await this.worldStore.refreshCharacter(); } catch (error) { this.actionErrorState.set(this.toMessage(error)); } finally { this.pendingState.set(null); } } dismissTradeSummary(): void { this.lastTradeState.set(null); } dismissPurchase(): void { 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; if (code && ERROR_MESSAGES[code]) { return ERROR_MESSAGES[code]; } } return GENERIC_ERROR; } }