309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
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<Record<string, string>> = {
|
|
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<NpcInteraction | null>(null);
|
|
private readonly exchangeState = signal<ExchangeView | null>(null);
|
|
private readonly shopState = signal<ShopView | null>(null);
|
|
private readonly panelState = signal<MerchantPanel>('DIALOGUE');
|
|
private readonly loadingState = signal(false);
|
|
private readonly errorState = signal<string | null>(null);
|
|
private readonly actionErrorState = signal<string | null>(null);
|
|
private readonly pendingState = signal<string | null>(null);
|
|
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();
|
|
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<void> {
|
|
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<string, number> = {};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|