veratown-prototype

Shop region proposal

Status: Implemented; archived 2026-10-18. This document is the original design record. For current behavior and operator instructions, see the shop guide.

Purpose and scope

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:

  1. Drinks. Spend credits on a roleplay drink.
  2. Release purchases. Spend credits to have the bot remove eligible restraints or locked items from yourself.
  3. Favors. Earn credits by consenting to a bounded, timed condition, such as “spend 10 minutes without clothing” or “wear itemset Y for Z minutes”.

Included

Explicitly unchanged

Design constraints

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.

Shop region and access

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:

Room rules

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
}

Offers

Drinks

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.

Release purchases

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:

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.

Cross-feature veto

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.

Charging rule

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

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:

Favor A: unclothed for X time

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.

Favor B: itemset Y for Z time

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.

Monitoring

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.

Commands

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.

Economy changes

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.

Persistence

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.

Architecture

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().

Abuse and safety limits

Phases

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

Test plan

Resolved implementation decisions

  1. The shop has its own shop region key; geometry is operator-created for the live map, not guessed or seeded.
  2. Drinks apply handheld appearance items and send a roleplay emote after the item is observed.
  3. Members may gift drinks and releases. The recipient must accept a short-lived private quote.
  4. Release supports configured eligible groups, unlocked items or supported bot-owned locks, and an injected Bunny veto. Release is disabled by default.
  5. Favor payout is all-or-nothing. Stopping forfeits only the unpaid reward; existing available credits are not seized.
  6. Leaving the shop region does not stop a favor, though it invalidates pending quotes.
  7. Itemset favors use B1 bot-scheduled removal without native timer locks.
  8. Disconnect, departure, or restart evidence gaps void favors without payout.
  9. Self-service launch offer: five minutes unclothed for 10 credits. Itemset favors are supported but require operator-managed safe definitions.
  10. Initial drink prices are 2, 2, and 3 credits; release baseline is 2 credits per item. Favor rewards are bounded by a 20-credit daily cap, relative to the casino’s 20-credit daily entry grant.

The current guide is authoritative for validation bounds, safe defaults, commands, persistence, and recovery behavior.