Status: Implemented; archived 2026-10-18. This document is the original design record. For current behavior and operator instructions, see the shop guide.
Add a shop region to the Veratown map. A character standing in that region can spend or earn shared economy credits through three kinds of offers:
shop feature plug-in that is statically composed in src/app/start.ts.MapLocationEventBus, using a
region location from veratownLocations, in the same way as casino roulette.shop.rules in prototypeGameConfig.rooms.main, edited through
the existing world-config admin commands with revision CAS writes.shop and reason codes for purchases and favor payouts.prototypeShopOrders, for durable purchase and
favor contracts./bot shop.BcClient remains the only Socket.IO owner. The shop adds no packet types and
no second connection.| Constraint | Consequence for the shop |
|---|---|
| Consent first | Every favor requires a quote, an explicit accept with a short-lived code, and an always-available stop. Consent is never carried across offers, restarts, or rooms. |
| Honest outcomes | A state-changing purchase is charged only after its effect is observed in BC state. timed_out_unobserved never charges or pays out. Emote-only drinks are the one documented exception (see Drinks). |
| Observed state | Location, appearance, and lock state come from inbound BC syncs. Command text never supplies a location, item state, or completion claim. |
| Idempotent ledger | Every economy call uses a stable operation ID derived from the persisted order ID, so replays after a crash or reconnect cannot charge or pay twice. |
| Feature isolation | The shop does not import Bunny, casino, or other features. Any cross-feature veto (for example, “do not sell release from an active Bunny punishment”) must be a core/ contract injected by the root. |
| Fail closed | Missing or invalid rules, a missing region, an unknown location, an unknown permission answer, or an unavailable journal disables the affected offer rather than using defaults. |
| Privacy | Logs and journal metadata contain IDs, offer keys, amounts, and statuses only. No chat text, appearance payloads, or lock credentials. |
| Adults-only room | Favors involving undress or restraints require adultsOnlyRoomConfirmed: true in the room rules, as an operator attestation like Dilemma’s. It is not age verification. |
The shop is bound to one region location in veratownLocations. Access is
granted on a server-observed locationEntered for that region and revoked on
locationLeft, disconnect, room leave, or a rules change. This reuses the
pattern in CasinoLocationAccess; the shop gets its own small access tracker
rather than importing casino code.
Rules for leaving the region:
Stored at prototypeGameConfig.rooms.main["shop.rules"], validated with zod at
the adapter boundary, and never seeded over an existing value.
interface ShopRules {
readonly schemaVersion: 1;
readonly enabled: boolean;
readonly adultsOnlyRoomConfirmed: boolean;
readonly regionLocationId: string;
readonly drinks: readonly DrinkOffer[]; // max 16
readonly release: ReleaseRules;
readonly favors: readonly FavorOffer[]; // max 16
readonly limits: ShopLimits;
}
interface DrinkOffer {
readonly id: string; // bounded slug, for example "tea"
readonly label: string; // max 40 chars
readonly priceCredits: number; // 1..100
readonly emote: string; // max 120 chars, admin-authored, never player text
}
interface ReleaseRules {
readonly enabled: boolean;
readonly pricePerItemCredits: number; // 1..500
readonly allowedGroups: readonly string[]; // for example ["ItemArms", "ItemLegs", "ItemFeet"]
readonly lockPolicy: "unlocked_only" | "bot_locked_only";
}
type FavorOffer =
| {
readonly id: string;
readonly kind: "unclothed";
readonly label: string;
readonly durationMs: number; // 1..60 minutes
readonly payoutCredits: number; // 1..500
readonly requiredEmptyGroups: readonly string[]; // for example ["Cloth", "ClothLower"]
}
| {
readonly id: string;
readonly kind: "itemset";
readonly label: string;
readonly itemsetKey: string; // a prototypeItemSets _id
readonly durationMs: number; // 1..60 minutes
readonly payoutCredits: number; // 1..500
};
interface ShopLimits {
readonly maxActiveFavorsPerMember: 1;
readonly favorCooldownMs: number;
readonly maxFavorPayoutPerDay: number;
readonly acceptCodeTtlMs: number; // default 60_000
}
A drink is a credit purchase with a roleplay emote such as
*The barkeep slides a cup of tea to Alice.*. The emote names the buyer using
the observed BC character name, never player-supplied text.
Flow: reserve the price, send the emote, then settle with a zero return. The
emote is dispatched_unconfirmed because BC has no chat acknowledgement. That
is acceptable here because the drink is flavor only and not a state change; the
proposal treats the reservation as the purchase record and settles it after the
emote is sent. If sending fails before emission, the reservation is refunded.
An optional later phase could add a handheld appearance item, such as a cup, as part of the drink. That uses the normal appearance path and follows the release purchase rules: charge only after the item is observed.
A member can buy removal of eligible items from themselves only (gifting is open question 3). The command first returns a quote listing eligible and ineligible items with reasons, then requires a confirm code.
An item is eligible only if, at quote time and again immediately before dispatch, the latest observed BC appearance shows that:
release.allowedGroups;lockPolicy: "bot_locked_only") its lock is one of
the five reviewed lock types and the lock’s observed LockMemberNumber is the
bot’s member number;ReleaseVeto contract objects to it (see below).The default is unlocked_only. bot_locked_only is offered because it uses BC’s
own observed lock metadata as the authority, not a journal or database record.
Restraints that another player applied and locked are therefore never sold off.
Removal uses the existing appearance remove mutation with
allowNonOwnerLock: true only for bot-locked items. There is no separate
“unlock but keep the item” operation in the current contract, so a release
always removes the item. BC permission denial fails closed.
Selling release from an active Bunny punishment would defeat that feature. The
shop must not read Bunny state directly. The proposal adds an optional
core/ contract:
interface ReleaseVeto {
/** Returns a reason when the item must not be released, or undefined. */
check(memberNumber: number, group: string): Promise<string | undefined>;
}
The composition root may inject one combined veto built from feature-provided
checks. Without a veto, bot_locked_only must stay disabled in rooms where
Bunny is enabled. This is open question 4.
stateDiagram-v2
[*] --> Quoted
Quoted --> Expired: code TTL / left region
Quoted --> Reserved: confirm code
Reserved --> Refunded: insufficient eligibility at re-check
Reserved --> Dispatched: remove mutation emitted
Dispatched --> Settled: removal observed
Dispatched --> Unconfirmed: timed_out_unobserved
Unconfirmed --> Settled: later observation shows item gone
Unconfirmed --> Refunded: later observation shows item still present
Dispatched --> Refunded: failed / denied before effect
Settled --> [*]
Refunded --> [*]
Each item is charged separately, so a partial release charges only for the observed removed items. An unconfirmed order keeps its reservation, which has no expiry in the economy, until reconciliation against a fresh observation. There is no blind retry.
Favors are the most sensitive part. Each favor is a durable contract with a clear start, an end time, an always-available stop, and a payout only after completion is observed.
Common rules:
/bot shop favor <id> whispers the exact terms
(condition, duration, payout, how to stop, what happens on early stop) and an
accept code. Only /bot shop accept <code> from the same member within
acceptCodeTtlMs starts the contract./bot shop stop ends the active favor immediately. For
itemset favors, it also triggers bot removal of the favor’s items. Stopping is
never penalized beyond losing the payout (open question 5 covers partial pay).grantOnce with source shop, reason
favor_payout, and an operation ID derived from the contract ID.The member removes their own clothing; the bot never strips or restores
clothing. The contract starts only when an observation shows every group in
requiredEmptyGroups empty. The contract fails (no payout) if an observation
shows any required group occupied before the deadline. It completes when the
deadline passes with no violating observation.
Because this is self-performed and monitored, it needs no appearance mutation, no lock, and no timer qualification.
The bot applies itemset Y to the member through the existing itemset service,
after the BC permission query. The contract starts only when all pieces are
observed; a partial or unconfirmed application does not start the clock
and the bot removes whatever was observed applied, with no payout.
Ending the contract needs a release mechanism. Two options:
| Option | How it ends | Risk |
|---|---|---|
| B1. Bot-scheduled removal | Itemset uses MetalPadlock or no lock. At the deadline or on stop, the bot removes the pieces with remove. |
Requires a durable deadline worker and restart recovery. If the bot is offline, the member stays restrained until the bot returns or someone else releases them. |
| B2. Native BC timer expiry | Itemset uses TimerPasswordPadlock with removeItemOnExpiry: true and duration Z. |
Depends on the same native timer behavior that failed Bunny qualification: local and room views diverged after expiry. Blocked until that qualification passes. |
| B3. Hybrid (recommended) | B1 for normal ending and stop, plus a timer lock of Z + grace as a backstop if the bot is offline. | Normal path does not depend on native expiry; the backstop does. The backstop still inherits the unqualified native-timer risk and must be documented as best effort only. |
Recommendation: implement B1 first, without any timer lock, and evaluate B3 only after native timer qualification passes. This is open question 7.
The payout requires the pieces to be observed present at the deadline and no observation during the contract showing them removed by someone else. If the member is released early by anyone, including through a release purchase, the favor fails without payout. A release purchase therefore cannot target items that belong to the member’s own active favor.
There is no continuous BC feed per character. The shop evaluates the contract on every inbound appearance observation for the member and at the deadline. A disconnect or room leave during an active favor is a gap in evidence. The proposal treats any gap as void without payout for itemset favors, after the bot removes its pieces once the member is observed again. Unclothed favors are voided on a gap too. This is strict but honest: the bot never pays for time it did not observe. Open question 8 offers a looser alternative.
All replies are private whispers. Commands outside the shop region reply with a short “only available in the shop” message and nothing else.
| Command | Behavior |
|---|---|
/bot shop / /bot shop help |
Scoped help for the shop; lists only enabled offer types. |
/bot shop list |
Lists drinks, the release price, and favors with durations and payouts. |
/bot shop buy <drinkId> |
Buys a drink. |
/bot shop release [group\|all] |
Quotes a release of eligible items and returns a confirm code. |
/bot shop favor <favorId> |
Quotes a favor with full terms and returns an accept code. |
/bot shop accept <code> |
Accepts the matching pending release or favor quote. |
/bot shop stop |
Stops the active favor at once; the bot removes favor-applied items. |
/bot shop status |
Shows the active favor, time remaining, pending orders, and balance. |
/bot world ... shop.rules |
Existing admin world-config commands; no shop-specific admin command is added. |
The shop reuses the existing operations. It needs only allowlist additions in
EconomyPort and EconomyService, which is a core/ change requiring a PR:
| Use | Operation | Source | Reason code |
|---|---|---|---|
| Drink purchase | Reserve, then settle with return 0 | shop |
purchase |
| Release purchase | Reserve, then settle or refund | shop |
purchase |
| Favor payout | Grant once, bounded | shop |
favor_payout |
The economy README’s statement that “there are no purchases” must be updated when this is implemented.
prototypeShopOrders, in the runtime-target database only:
type ShopOrder =
| {
readonly _id: string; // stable order ID, also the economy operation ID root
readonly kind: "drink" | "release";
readonly memberNumber: number;
readonly status: "reserved" | "dispatched" | "unconfirmed" | "settled" | "refunded";
readonly items?: readonly { group: string; asset: string; status: string }[];
readonly createdAt: Date;
readonly finalizedAt?: Date;
readonly expiresAt?: Date; // finalizedAt + 60 days
}
| {
readonly _id: string;
readonly kind: "favor";
readonly memberNumber: number;
readonly favorId: string;
readonly rulesRevision: number;
readonly status:
"applying" | "active" | "releasing" | "completed" | "stopped" | "failed" | "void";
readonly startedAt?: Date;
readonly deadlineAt?: Date;
readonly payoutCredits: number;
readonly createdAt: Date;
readonly finalizedAt?: Date;
readonly expiresAt?: Date;
};
Indexes: a partial unique index on { memberNumber: 1 } for active favor
statuses (enforces one active favor), and a TTL index on expiresAt. Active and
unconfirmed records have no expiresAt.
flowchart LR
Root["start.ts"] --> ShopPlugin["ShopPlugin"]
ShopPlugin --> Access["ShopLocationAccess"]
ShopPlugin --> Commands["ShopCommandSuite"]
Commands --> Service["ShopService"]
Service --> Economy["EconomyPort"]
Service --> Appearance["Appearance action / itemset service"]
Service --> Veto["ReleaseVeto (core, optional)"]
Service --> Repo["MongoShopRepository"]
Service --> Worker["FavorDeadlineWorker"]
Access --> MapBus["MapLocationEventBus"]
Repo --> Orders[("prototypeShopOrders")]
The plug-in creates subscriptions and the deadline worker in start() and
disposes all timers and listeners in stop(). Deadline timers use .unref().
BcClient limiter.| Phase | Content | Gate before next phase |
|---|---|---|
| 1 | Rules schema, region access, list/help, drinks, economy allowlist |
Unit tests and a manual test-room run |
| 2 | Release purchases with unlocked_only |
Manual observation run with the dedicated test account |
| 3 | Favor A (unclothed, self-performed, monitored) | Manual observation run; consent wording review |
| 4 | Favor B1 (itemset, bot-scheduled removal), ReleaseVeto, bot_locked_only |
Manual observation run; restart recovery run |
| 5 | Optional: B3 timer backstop | Only after native timer qualification passes |
timed_out_unobserved reconciliation; favor contract transitions, stop,
gaps, restart recovery; idempotent operation IDs.shop region key; geometry is operator-created for the
live map, not guessed or seeded.The current guide is authoritative for validation bounds, safe defaults, commands, persistence, and recovery behavior.