Shops
The key words MUST, MUST NOT, REQUIRED, SHALL, SHOULD, MAY, and OPTIONAL below are used as described in RFC 2119 — they define expected behavior, not current implementation status.
A shop is where the player exchanges one resource for another: meseta for goods, Photon Drops for goods, materials for crafted gear, or items between the inventory and the bank. Every shop presents the same skeleton and the same input grammar so a player who has learned one shop has learned them all. The storage counter is built from the same skeleton and is treated as a shop for the purposes of this contract, even though it moves items rather than selling them.
The shop is presented diegetically: rather than cutting to a separate full-screen menu, the game keeps the player in the world. On interaction the camera moves to a fixed angle directly in front of the shopkeeper NPC — who stays exactly where they stand in the city, at their authored position and rotation — the player character is ghosted so it never hides the clerk, and the shop UI is drawn as an overlay on top of the still-rendered 3D scene. Closing the shop returns the camera to following the player and restores the player to solid. The framing contract is in Presentation; the UI skeleton, input grammar, and economy rules below are unchanged by it.
Shops MUST share behavior through composition — preloaded static helpers (PszStyle, ShopNav, ShopUI) — and MUST NOT share it through a common base class. A cross-script base class fails to resolve in the Android export at runtime (it works in-editor and on every other platform); see docs/shop-dedup.md. Each shop is therefore its own extends Control script that calls into the shared helpers.
Presentation — diegetic 3D framing
The shop screen is drawn over the live city, not in place of it. When the player accepts an interaction with a shop NPC, the game MUST:
- Keep the 3D scene rendered. The city area, the shopkeeper NPC, and the player MUST NOT be unloaded; the shop UI is an overlay above them. Cancelling returns to gameplay with no reload.
- Move the camera directly in front of the NPC. The camera MUST tween from the follow camera to a fixed pose on the NPC's own facing axis, looking head-on at the shopkeeper at roughly chest height. The NPC MUST NOT turn — it keeps its authored city position and rotation; the camera moves to face it. The NPC MUST be framed on the right of the screen so the menu has the left. On close the camera MUST tween back to the follow camera.
- Ghost the player. While the shop is open the player model MUST be rendered semi-transparent (a "ghost") so it never occludes the shopkeeper or the menu, and MUST be restored to fully solid on close. (The player stands off to the side and slightly toward the camera; ghosting keeps the clerk and panels readable without hiding the customer entirely.)
- Lay the UI out left-of-NPC, two panels side by side. The list (the body — tabs + scrolling rows + balance + hint) is a tall panel on the far left spanning ~90% of frame height. The info / detail panel sits immediately to its right, top-aligned, ~40% of frame height. The confirm/action control MUST stay pinned within the info panel (never clipped by its shorter height). Panels use the standard PSZ chrome (Style A: pale-blue glass with the navy title bars).
- Render at the game's native 1280 × 720 (16:9), letterboxed to fit the display.
Everything below — the row grammar, tabs, detail-panel contents, feedback, and economy rules — is unchanged by the framing; it describes the same panels, now composited over the 3D scene rather than a flat background.
The common interface
Every shop screen MUST:
- Lay out a fixed skeleton, top to bottom: a title, a tab bar (the "mode bar"), a content row holding a scrolling list (and, where applicable, a detail panel), and a hint line. A portrait of the shopkeeper MUST be overlaid on the right via
ShopUI.setup_portrait(). - Drive navigation through
ShopNavso the input grammar is identical everywhere (see below). - Render every list entry through
PszStyle.shop_row()(see The row contract), and the tab bar throughPszStyle.create_tab_bar(). - Show the player's spendable balance — meseta or Photon Drops — in the top-right of the tab row, not inline in the list. The title MUST sit above center so the on-screen keyboard cannot cover it.
Input grammar
All shops MUST honor this mapping, dispatched by ShopNav.handle():
- Left / Right — switch tab or mode. The selection index MUST reset to the top of the new tab's list.
- Up / Down — move the selection within the current list. Hold-to-repeat SHOULD be supported.
- Accept — confirm the action on the selected row. A shop SHOULD open a confirmation modal before any irreversible transaction.
- Cancel — close the shop and return to the city. It MUST NOT charge the player or mutate inventory.
List scrolling
A list longer than its panel scrolls. The behavior MUST be identical across every shop and the storage counter:
- The list MUST sit in a vertically-scrolling container (horizontal scrolling disabled) — built via
PszStyle.make_list_scroll()so the setup can't drift. - Moving the selection MUST keep the selected row visible: when the selection moves past the visible edge, the list MUST scroll to follow it. Buy/exchange shops and the storage counter MUST call
PszStyle.scroll_selected_into_view(row), which keeps a row of context between the selection and the viewport edge so the cursor travels within the visible area and the list only scrolls as the selection nears an edge — rather than the scrollbar jumping on every move (Kion/Rozalin playtest). Both helpers walk up from the row to find the container and run only after the row is parented under its scroll (before that they no-op). The plain flushPszStyle.scroll_into_view(row)remains for any list that still rebuilds per move. This was the storage bug: it scrolled off a not-yet-parented row and silently never followed the selection. - A shop SHOULD update the selected rows in place on a cursor move rather than rebuilding the whole list: rebuilding recreates the
ScrollContainerand resets it to the top, which makes the scrollbar jump on every keypress. The scroll-follow behavior MUST be the same either way.
The row contract
A list row carries an optional icon, a name, and a right-aligned value (price, count, or yield). One rule governs every shop so the disabled state reads the same everywhere:
- A buy-shop row MUST be greyed when, and only when, the character cannot use or equip the item — a capability limit, not an economic one. This matches PSOBB (Rozalin playtest 2026-06-22): "can your character theoretically use or equip this? No → grey." The capability blocks are:
- Gear (weapon / armor) the character's race/class can never equip.
- Technique disks the character cannot learn — a race/class that can never learn it (CASTs have no technique access; other classes can't learn certain groups or past a per-group cap), a disk for a technique already known at that level or higher, or a disk above the character's required level. (These are exactly
TechniqueManager.can_learn's rejections; the permanent race/class slice isclass_can_learn.) - Consumables the character's class/race cannot use, once consumable usage restrictions exist (see the open question below).
- Meseta affordability and inventory space MUST NOT grey a row. An item the player can't afford in meseta, or has no room for, or already holds a full stack of (e.g. 10/10 Monomates), stays a normal row — purchase power doesn't change whether the item is worth looking at. The block is enforced at accept, not by greying — see failure modals. (This is the reversal of the old #368 rule, which greyed on affordability; it caused the inconsistency Rozalin flagged where a full inventory greyed items/materials but not disks.) Exception — the Photon Collector greys rows the player lacks the Photon Drops for: PD is the gating resource of a barter exchange (not general purchase power), and PSOBB greys it (Rozalin playtest). This is the one shop where the row's own currency greys it.
- A row the character can never use via race/class MUST additionally carry a ✕ marker in the leftmost marker slot (
PszStylecannot_use), making "you can't use this" unmissable. This covers gear they cannot equip (in the weapon shop's buy list, the item and weapon shop SELL lists, the storage counter's deposit/withdraw item lists, and the field start-menu inventory), technique disks their race/class can never learn (a CAST, a technique group the class can't learn, or a disk level beyond the class's cap —TechniqueManager.class_can_learn), and synthesis recipes whose output weapon the class can't equip (the synth shop still lets them craft it — the ✕ is a visual filter for which recipes are worth their materials). A merely temporary or already-satisfied block (a disk below the required player level, or already known at that level) greys without the ✕ marker (disabled). Open question (Rozalin): in PSOBB some unusable consumables (antidote/antipara on a CAST) grey without the ✕ — the rule for consumables is not yet understood, so the ✕ currently covers gear equip-blocks and disk class-blocks only. The gear equip-block uses the canonical equip-legality gate —ClassData.allowed_weapon_typesviaEquipmentUtils.item_fits_slot(the sell lists route throughShopNav.sell_cannot_use) — notWeaponData.usable_by, which has drifted; see Equip Legality. - A greyed row stays selectable and purchasable: the player MAY still buy gear/disks/items they can't use, provided they can afford it and have inventory room (Kion: "we let people buy it anyway if they have the inventory space + money"). Greying is a clarity aid, never a buy-block.
- The
[E](equipped) and ✕ (can't-use) markers MUST share a single fixed-width leftmost slot, before the item icon, and every row MUST reserve that slot (left empty when it has no marker) so item names line up down the whole list (PSOBB convention; Rozalin playtest). A row carries at most one marker — ✕ takes precedence over[E]. Equipped gear is muted; a trailing[E]tag (the item shop's old sell-list style) is NOT allowed. Shops MUST NOT use a second colour (red, yellow) to distinguish why a row is greyed — grey is grey. - A muted row greys the whole row, including its item icon — the leading icon is dimmed (
PszStyle.DISABLED_ICON_MOD) along with the text, so a greyed row never shows a full-colour icon next to faded text. The ✕ marker itself stays full-colour (it is a marker, not item art). This MUST hold on every item-list surface — the shop buy/sell lists and the field start-menu inventory. - One disabled appearance across surfaces. The grey text colour (
PszStyle.TEXT_MUTED), the icon-dim modulate, and the row typeface MUST be the same in the field start-menu inventory as in the 2D shop lists — the start menu draws in immediate mode, so it explicitly references those same values and renders in the shop font (rpg_theme→ JetBrains Mono) rather than the engine fallback face. An already-known disk MUST look the same whether the player sees it in a shop or the inventory (Rozalin #417: "the graying color isn't identical between the two… the font doesn't seem identical"). - Rarity stars, required level, held count, the capability caveat (Cannot equip: class / Cannot use: class), and the can't-buy reason MUST appear in the detail panel, not inline in the row label. The row stays short: name, value, and at most the item's own identity (e.g. a weapon's grind,
Saber +3). - Un-equippable gear stays buyable, and its buy confirm uses the normal prompt ("Buy X for N M?") — not a "buy anyway?" warning. The row's ✕ marker already tells the player they can't equip it, so the extra wording is redundant. The hard buy-blocks remain affordability and inventory room only. (Resolves the economy decision raised in issue #375 — gear stays buyable; the grey + ✕ carry the caveat, replacing the earlier warning-modal wording.)
Open question — consumable usage restrictions
Weapons and armor carry a usable_by class/race restriction (WeaponData / ArmorData can_be_used_by); the same field now exists on ConsumableData but no consumable populates it yet, so every consumable is currently usable by all and the item-shop capability grey is a no-op for the Items/Materials tabs. Resolving which consumables a class/race cannot use — and specifically why PSOBB greys antidote/antipara on a CAST without the ✕ marker while greying techniques with it — is the follow-up that makes the Items tab match PSOBB. Until then the item shop drops only the (incorrect) affordability/full-inventory greying.
The detail panel
The detail panel is the right-hand column that describes the selected row. Where a shop presents items with stats or effects, it MUST render them here rather than in the list. It MUST distinguish item kinds:
- Gear (weapon / armor / unit) — rarity stars, then the stat block (type, ATK/ACC, DEF/EVA, element, grind, slots, effect).
- Consumables / materials — the effect text and the count the player holds.
- A status line where relevant (e.g. Can equip / Cannot equip: class, or Storable / Equipped — cannot deposit).
A shop whose entries have no per-item stats (a flat exchange list) MAY omit the detail panel and present a single full-width list. Such list-only shops SHOULD adopt the detail panel as their content grows. (photon / crafting / tekker are list-only today; migrating them to the standard list + detail layout is tracked on issue #368.)
Feedback: sounds, messages, and failures
Every economy screen — the shops, storage, the guild counter, and Blackjack — MUST give the same audible and textual feedback for the same event. Today each screen hand-rolls this and they diverge (issue #368, Rozalin playtest): three different "can't buy" modals, two different full-inventory strings, and per-screen sound choices. The rules below are the single contract; a screen MUST route through the shared helpers (below) rather than re-deciding any of it.
Sounds
One sound per event class, identical on every economy screen:
- Move (Up/Down/Left/Right, including tab change) — the navigation cue.
- Accept on an actionable row — the accept cue, then the confirm/quantity modal opens.
- Close / Cancel — the back cue. It MUST play on every screen, Blackjack and the storage / quest counter included (these are silent today).
- Accept on a greyed (un-actionable) row, or any action that fails at commit — the denied cue, played once, and nothing opens. A screen MUST NOT emit the back/close cue here (the item shop plays a close sound on a blocked buy today), and MUST NOT emit the accept cue (which reads as success). The denied cue MUST be the same one for every blocked action on every screen, played once — never silent (the press must register), never the back/close cue, never the accept cue.
Messages
Failure text comes from one shared vocabulary, defined once (a shared constant), never a per-screen literal:
- Inventory has no free slot → "No room" (the item/weapon-shop wording; the photon collector's "Inventory Full" is the outlier and MUST be retired).
- Insufficient currency → "Not enough meseta" (or the screen's currency name).
- Any other block (missing materials, wrong level, can't-equip) → states the specific reason, in the detail panel per the row contract.
Failure modals
- A capability-greyed row (can't use/equip) is not inert — it stays buyable. Accept on it MUST proceed to the normal buy confirm ("Buy X for N M?"); the ✕ marker already conveys un-equippability, so there is no "buy anyway?" wording. (This supersedes the older "a greyed row is inert" rule, which applied when greying meant unaffordable; affordability no longer greys, so there is no longer an inert greyed row.)
- Because affordability and inventory space no longer grey a row, a buy the player cannot complete — can't afford, no room, or stack full — MUST be blocked at accept with the shared info modal: the denied cue plus the specific reason ("Not enough meseta", "No room"). This MUST be identical on every shop (item, weapon, disk, and photon collector all route through
ShopNav.deny) — resolving Rozalin's report that the photon collector opened a modal where the others did not. A blocked buy MUST go straight to that info modal, never through the buy confirm first: in particular the disk buy MUST pre-check meseta/room before opening its confirm (it previously double-modalled — confirm, then "no room"). A silent hint is NOT acceptable here (it reads as a broken buy button — playtest 2026-05-07). - When a transaction the player could start fails at commit (a race, or a multi-resource exchange like a photon reward), the failure MUST be reported with the specific reason — what is missing — not a bare "failed." The photon collector's "you are missing X" modal is the correct model; every screen MUST use the same modal style for this, not its own.
Quantity
- Buying a stackable MUST open the shared quantity stepper. Selling a stackable MUST open the same stepper (both the item and weapon shop sell tabs route through
ShopNav.sell_confirm, which mirrors the buy flow — #416, resolving Rozalin's report that selling a Monomate stack only sold one per confirm). Single-instance gear (a specific weapon/armor/unit/mag/disk) is acted on one at a time, with no stepper.
Tab chrome
- The pill tab bar MUST be built by the shared
PszStyle.create_tab_bar()so its size, spacing, and position are identical across every shop and the storage counter (they differ today). A screen MUST NOT lay out its own pills.
How each shop differs
Within the shared skeleton, each shop specializes its tabs, its currency, and what "confirm" does:
| Shop | Tabs / modes | Currency | Confirm does | Detail panel |
|---|---|---|---|---|
| Item Shop | Items · Materials · Disks · Sell | Meseta | Buy a consumable / technique disk, or sell back at half price | Effect, cost, max stack, held count (qty stepper on buy + sell) |
| Weapon Shop | Weapons · Armor · Units · Sell | Meseta | Buy gear, or sell owned gear | Gear stats + Can equip / Cannot equip: class |
| Photon Shop | — (single list) | Photon Drops | Exchange Photon Drops for a reward item | List-only |
| Synthesis (Crafting) | Craft · Boards | Meseta + materials | Craft a weapon from a recipe; a photon crystal sets the element / special tier | List-only (materials "have of need" planned) |
| Tekker | — (Grind only) | Meseta + grinders | Grind a weapon up (raise its attack power) | List-only |
| Storage | Deposit Items · Withdraw Items · Deposit Meseta · Withdraw Meseta | — (no cost) | Move items / meseta between the inventory and the bank | Gear stats / item effect + count, storable status |
| Guild Counter (quest board) | Quest list · accept / report / cancel | — (no cost) | Accept, report, or cancel the selected quest | Quest brief — area, type, objective |
The guild counter shares this skeleton and input grammar — it is effectively a shop — so it sits in the table. It diverges in one place: its rows are not the buy-affordance row contract (see below).
Shop-specific rules
- Item Shop. Selling MUST return half the buy price. The Materials tab is debug-only (not purchasable) and MUST be marked as such. Disk purchases MUST be one-at-a-time and MUST surface the required level and the player's known technique level in the detail.
- Weapon Shop. The detail panel MUST state whether the active character's class can equip the selected gear. Selling MUST exclude currently-equipped items.
- Synthesis. A recipe's required materials MUST be shown as have of need (e.g.
2 of 3), coloured to show whether each is met. The recipe MUST NOT be craftable until every material and the meseta cost are satisfied. - Storage. The deposit/withdraw item lists MUST use the same row contract as the shops: the leftmost marker slot, the ✕ on gear the class can't equip, and
PszStyle.scroll_selected_into_viewfor the cursor-follow (with the incremental in-place update, not a full rebuild per move). Currently-equipped gear MUST NOT be depositable; it MUST be shown greyed with the[E]marker and a Equipped — cannot deposit status. The four tabs MUST each be a distinct, unambiguous destination so Left/Right cannot trap the player on one direction.
The guild counter: same layout, different rows
The guild counter is effectively a shop — it shares the exact skeleton above (title, tab bar, list, detail, portrait, hint) and the same input grammar. What it does not share is the row contract: its row colours encode quest-board action types — report, cancel, locked, cleared — not buy affordance, and the red "Cancel Quest" row is a deliberate destructive-action cue. So the actual shops MUST NOT borrow those colours for affordance, and the guild counter MUST NOT be flattened into the single-grey disabled rule. See Story Progression for the counter's REPORT / LOCKED / CLEAR derivation.
Conformance
- Open any shop. The title sits above center; the balance is top-right of the tabs; the hint line names the Left/Right, Up/Down, Accept, Cancel actions.
- Move Left/Right — the tab changes and the selection resets to the top.
- Move Up/Down onto a row you cannot afford — it is greyed (one grey, no second colour), and the detail panel states the reason.
- Select equipped gear (weapon shop sell, or storage deposit) — the row shows
[E]as a prefix and is greyed; the detail explains it cannot be sold / deposited. - Press Cancel — the shop closes with no charge and no inventory change.
Implemented by
scripts/ui/psz_style.gd(PszStyle) — shared styling:shop_row()(the row contract),create_tab_bar(),create_meseta_label(),create_pill_with_icons(),detail_label(), and the list-scroll primitivesmake_list_scroll()+scroll_into_view().scripts/2d/shops/shop_nav.gd(ShopNav) — the input grammar:handle()dispatch,selected_item(),confirm()/info()modals,switch_shop_tab().scripts/2d/shops/shop_ui.gd(ShopUI) —setup_portrait(), the two-column content + portrait-overlay layout.scripts/2d/shops/item_shop.gd,weapon_shop.gd,photon_shop.gd,crafting_shop.gd,tekker.gd— the five shops, eachextends Control.scripts/2d/storage.gd(Storage) — the bank counter, same skeleton, backed byGameState.stored_mesetaandGameState.shared_storage.scripts/2d/guild_counter.gd(GuildCounter) — the quest board; same skeleton, distinct row semantics (not the shop row contract).