This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View 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;
}
}