docs
This commit is contained in:
264
apps/web/src/app/features/npc/merchant.store.ts
Normal file
264
apps/web/src/app/features/npc/merchant.store.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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.',
|
||||
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>>({});
|
||||
|
||||
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();
|
||||
|
||||
/** 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.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)));
|
||||
this.shopState.set(
|
||||
this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user