import type { Balance, Entitlements, HelixEvent, HelixRequestMethod, LeaderboardOrder } from '../protocol.js'; import { type DevShellProductsSeed, type DevShellState } from './state.js'; /** Stamped on every simulated answer. Never call this mode "test" — Roblox's test mode costs real money. */ export declare const DEV_SHELL_LOG_PREFIX = "[helix dev \u00B7 SIMULATED]"; /** * The SHELL lane's real limits, with their production sources. Enforcing them is what keeps a local pass * meaningful — and matching the loud shapes exercises a world's error paths for free. */ export declare const DEV_SHELL_CAPS: { /** data-store.constants.ts DATA_STORE_USER_WRITE_LIMIT_PER_WINDOW. */ readonly playerWritesPerMinute: 30; /** The free KvStoragePlan's writeOpsPerMinute (DATA_STORE_WRITE_LIMIT_PER_WINDOW is its fallback). */ readonly worldWritesPerMinute: 600; /** entitlements.service.ts CONSUME_RATE_MAX. */ readonly consumesPerMinute: 120; readonly windowMs: 60000; /** DATA_STORE_MAX_VALUE_BYTES. */ readonly maxValueBytes: number; /** DATA_STORE_MAX_KEYS / DATA_STORE_MAX_TOTAL_BYTES. */ readonly maxKeys: 50000; readonly maxTotalBytes: number; /** DATA_STORE_LIST_MAX_LIMIT. */ readonly listPageMax: 100; /** LeaderboardController.DEFAULT_LIMIT, and the SDK's own MAX_LEADERBOARD_LIMIT. */ readonly leaderboardPageDefault: 25; readonly leaderboardPageMax: 100; /** DATA_STORE_SESSION_BOARD_CAP — boards a session may lazily create. */ readonly sessionBoardCap: 8; /** LEADERBOARD_DEFAULT_SIZE — entries a board keeps. */ readonly boardSize: 100; }; /** * How long a real-flow poke (a data-store write, a purchase settle) waits before criteria re-evaluate — * the backend's ACHIEVEMENT_CRITERIA_DEBOUNCE_MS default. Debug-menu evidence evaluates immediately * (the local analog of the admin sweep). Local unlocks are therefore never SLOWER than production's. */ export declare const DEV_SHELL_ACHIEVEMENT_DEBOUNCE_MS = 3000; /** What the host page hears when a simulated unlock happens. Worlds hear nothing pushed — the protocol * has no achievements event, so a world observes an unlock by re-polling, exactly as in production. */ export type DevShellEarnedAchievement = { key: string; name: string; points: number; hidden: boolean; via: string; }; /** One row of the debug menu's progress print: how far each registered achievement is from unlocking. */ export type DevShellAchievementProgress = { key: string; name: string; earned: boolean; unlockMode?: string; criteria: string | null; measured: number | null; }; /** What the host's simulated confirm popup shows — the real shell's dialog fields for one attempt. */ export type DevShellPurchaseConfirm = { ref: string; idempotencyKey: string; title: string; priceLix: number; isFree: boolean; balanceLix: number; balanceAfterLix: number; }; /** How the next purchase fails while failure injection is armed. `Pending` throws and leaves the row. */ export type DevShellPurchaseFailureMode = 'InsufficientFunds' | 'PriceChanged' | 'Pending'; export declare const DEV_SHELL_PURCHASE_FAILURE_MODES: readonly DevShellPurchaseFailureMode[]; /** One answer to one helix:request — mapped straight onto the helix:response envelope. */ export type DevShellAnswer = { ok: true; result?: unknown; } | { ok: false; error: string; errorCode?: string; }; export type DevShellLogger = (message: string, detail?: unknown) => void; /** One declared room type, the two manifest fields the join API reads (`multiplayer.roomTypes.`). */ export type DevShellRoomTypeSeed = { access: 'open' | 'ticketed'; maxPlayers: number; }; /** The world's declared room types, keyed by name. Mirrors the manifest block; the default type is implicit. */ export type DevShellRoomTypesSeed = Record; /** The manifest validator's room-type name grammar (`default` is reserved, at most 8 types). */ export declare const DEV_SHELL_ROOM_TYPE_NAME_PATTERN: RegExp; export declare const DEV_SHELL_ROOM_TYPES_MAX = 8; /** * How long a simulated ticket stays redeemable. The contract pins the ticket CREDENTIAL at 60 s; the ticket * row's own lifetime is the backend's choice, so this is a stand-in, overridable per ticket (`ttlMs`). */ export declare const DEV_SHELL_ROOM_TICKET_TTL_MS = 60000; /** The `helix:room-ticket` payload, with `expiresAt` in epoch milliseconds (what room.onTicket hands a world). */ export type DevShellRoomTicket = { roomType: string; ticketId: string; instanceId: string; expiresAt: number; }; /** The join request body: none / `{}` = default type, `{ roomType }` = an open type, `{ ticket }` = redeem. */ export type DevShellJoinBody = { roomType?: string; ticket?: string; }; /** * The simulated join answer. On success it carries exactly the two optional JoinResponse fields the SDK * forwards as join options — and nothing a client could connect with: no wsUrl, no credential. */ export type DevShellJoinAnswer = { ok: true; status: 200; roomType?: string; instanceKey?: string; } | { ok: false; status: 403 | 404 | 410; body: DevShellRoomRefusalBody; }; export type DevShellRejoinAnswer = { ok: true; status: 200; ticket: DevShellRoomTicket; } | { ok: false; status: 404 | 410; body: DevShellRoomRefusalBody; }; /** * The backend's refusal `code`s for the room-types routes, with the status each is served under. A client * branches on the CODE: two of the 410s and two of the 404s mean different things, and an unrelated 404 * (world not found) shares a status with all of them. */ export declare const DEV_SHELL_ROOM_REFUSALS: { readonly 'unknown-room-type': 404; readonly 'ticket-required': 403; readonly 'unknown-room-ticket': 404; readonly 'room-ticket-expired': 410; readonly 'room-ticket-used': 410; readonly 'room-instance-closed': 410; readonly 'unknown-room-instance': 404; }; export type DevShellRoomRefusalCode = keyof typeof DEV_SHELL_ROOM_REFUSALS; /** The error body the backend serves for a refused room-types request. */ export type DevShellRoomRefusalBody = { statusCode: number; code: DevShellRoomRefusalCode; message: string; }; /** One declared queue, the manifest fields the routes read (`matchmaking.queues.`). */ export type DevShellQueueSeed = { roomType: string; teams?: number; teamSize?: number; }; /** The world's declared queues, keyed by id. Omitted / empty = matchmaking is not enabled for this world. */ export type DevShellQueuesSeed = Record; export declare const DEV_SHELL_QUEUE_ID_PATTERN: RegExp; export declare const DEV_SHELL_QUEUES_MAX = 16; /** The platform's party size cap. */ export declare const DEV_SHELL_PARTY_MAX_SIZE = 12; /** Pending invites one party may have out at a time. */ export declare const DEV_SHELL_PARTY_PENDING_INVITES_MAX = 10; /** How long a party invite stays answerable. */ export declare const DEV_SHELL_PARTY_INVITE_TTL_MS: number; /** What a simulated v1 route answers: the HTTP status and the JSON body (`undefined` for a 204). */ export type DevShellPlatformAnswer = { status: number; body?: unknown; }; export type DevShellPartyMember = { userId: string; displayName: string; }; export type DevShellParty = { id: string; leaderId: string; maxSize: number; members: DevShellPartyMember[]; }; /** A party join code (`expiresAt` is ISO-8601 on the wire). */ export type DevShellPartyCode = { code: string; expiresAt: string; }; /** How long a simulated join code lasts. */ export declare const DEV_SHELL_PARTY_CODE_TTL_MS: number; /** An incoming invite, as `GET /parties/current` lists it (`expiresAt` is ISO-8601 on the wire). */ export type DevShellPartyInvite = { id: string; partyId: string; from: DevShellPartyMember; expiresAt: string; }; export type DevShellSimulatorOptions = { state: DevShellState; products?: DevShellProductsSeed; /** The world's declared room types (the manifest's `multiplayer.roomTypes`). Omitted = none declared. */ roomTypes?: DevShellRoomTypesSeed; /** The world's declared matchmaking queues (the manifest's `matchmaking.queues`). Omitted = none declared. */ queues?: DevShellQueuesSeed; /** Pushes a helix:event to the world. */ emit: (event: HelixEvent, data?: unknown) => void; log: DevShellLogger; onStateChange?: (state: DevShellState) => void; /** Host-page unlock feedback (toast). Never forwarded to the world. */ onAchievementEarned?: (achievement: DevShellEarnedAchievement) => void; /** Real-flow poke delay. Default DEV_SHELL_ACHIEVEMENT_DEBOUNCE_MS; 0 evaluates synchronously (specs). */ achievementDebounceMs?: number; }; /** The 21 methods `helix dev` answers from simulated state. */ export declare const DEV_SHELL_SIMULATED_METHODS: readonly HelixRequestMethod[]; /** The 13 methods `helix dev` refuses — they need a real platform surface, asset pipeline, camera rig, * or (the quick-slot six) the platform's one-place state machine and the creator's own owned instances. */ export declare const DEV_SHELL_REFUSED_METHODS: readonly HelixRequestMethod[]; export declare class DevShellSimulator { private readonly options; private state; private products; private listings; private playerWrites; private worldWrites; private consumes; private consumeOps; private purchaseFailure; private forceSaveFailure; private achievementPokeTimer; private roomTypes; private roomInstances; private roomTickets; private roomSerial; private queues; private queueTicket; private party; private partyInvites; private outgoingInvites; private partyCode; private queueEnded; private memberWithMatch; private foreignCodes; private socialSerial; constructor(options: DevShellSimulatorOptions); /** Route one helix:request. Never throws — a thrown handler would strand the world's promise. */ handle(method: HelixRequestMethod, payload: unknown): DevShellAnswer; snapshot(): DevShellState; replaceState(state: DevShellState): void; setProducts(seed: DevShellProductsSeed | undefined): void; /** Cancel a pending debounced evaluation — the dispose() seam. A cancelled poke heals on the next one. */ cancelPendingAchievementPoke(): void; private log; private touched; private emit; private charge; private rateLimited; getBalance(): DevShellAnswer; getListings(query: Record): DevShellAnswer; /** * What the host's confirm popup should show for this attempt, or null when production would not pop * one either: a missing ref/key (the request will refuse), an unknown ref (ProductNotFound is the * world's own bug), or a settled row (the answer is an idempotent replay, no new confirmation). */ purchaseConfirmContext(ref: string, idempotencyKey: string): DevShellPurchaseConfirm | null; /** * The popup's Cancel: nothing was charged, nothing is recorded — production's cancel-before-charge * leaves no backend row, so a retried attempt (same key) confirms again instead of replaying Cancelled. */ cancelPurchase(ref: string): DevShellAnswer; /** * Settle a purchase against simulated state. NEVER mints a receipt: settlePurchaseResult forwards on * receipt PRESENCE (index.ts:1156-1162) and a junk receipt reaching a real room kicks the buyer. */ purchase(ref: string, idempotencyKey: string): DevShellAnswer; private injectedPurchaseFailure; private recordPurchase; getPurchase(idempotencyKey: string, ref?: string): DevShellAnswer; getPurchaseContext(ref: string): DevShellAnswer; getEntitlements(): DevShellAnswer; /** Atomic floor-at-zero spend, idempotent per opId — the same shape the backend's shaped op returns. */ consumeEntitlement(code: string, amount: number, opId: string): DevShellAnswer; hasItem(itemId: string): DevShellAnswer; getQuantity(itemId: string): DevShellAnswer; getMyItems(): DevShellAnswer; equipItem(itemId: string): DevShellAnswer; dataStoreGet(key: string): DevShellAnswer; dataStoreGetEntry(key: string): DevShellAnswer; dataStoreSet(key: string, value: unknown, expectedVersion?: number): DevShellAnswer; dataStoreDelete(key: string, expectedVersion?: number): DevShellAnswer; dataStoreList(payload: Record): DevShellAnswer; private storedBytes; private chargePlayerWrite; private chargeWorldWrite; private refuseWrite; private versionConflict; leaderboardSubmit(board: string, score: number, order?: LeaderboardOrder): DevShellAnswer; leaderboardTop(board: string, limit?: number, order?: LeaderboardOrder): DevShellAnswer; achievementsList(): DevShellAnswer; achievementsMine(): DevShellAnswer; /** * The player's equipped emote wheel — a STUB, and one that says so on every petal. * * There is no account here to read a wheel from, so the only two honest answers were a refusal or a * loadout that cannot be mistaken for a real one. A refusal is what this used to be by omission, and * it was the wrong one: the engine's native avatar runtime reports an unanswered read as "Couldn't * load your emotes", so `helix dev` — the platform's own local command — could not show a creator * the wheel at all. * * Read from state rather than returned as a constant so a creator can point it at their own clips by * editing .helix/dev-state.json, and so `--reset` restores the stub. An older state file has no * `emotes` field, hence the fallback. */ emotesGetEquipped(): DevShellAnswer; private summaryOf; private pokeAchievements; private evaluateAchievements; private criteriaEvidence; /** Append to earned + notify the host. The caller batches touched(); no world event exists to push. */ private earnAchievement; grantAllEntitlements(): void; grantEntitlement(ref: string): void; removeEntitlement(ref: string): void; entitlementsSnapshot(): Entitlements; setPurchasesAlwaysFail(mode: DevShellPurchaseFailureMode | 'off'): void; /** The debug-menu button: off → InsufficientFunds → PriceChanged → Pending → off. */ cyclePurchasesAlwaysFail(): DevShellPurchaseFailureMode | 'off'; purchasesAlwaysFail(): DevShellPurchaseFailureMode | 'off'; resetPlayerDocument(): void; setForceSaveFailure(on: boolean): void; forceSaveFailureOn(): boolean; /** Fill the consume window so the next consume hits the real 120/min refusal. */ exhaustConsumeFence(): void; setWallet(wallet: Partial): void; /** The room/platform award lanes' stand-in. Refuses a key outside the registry, as the backend would. */ awardAchievement(key: string): boolean; /** Local-only re-test convenience: production never revokes. Finer-grained than --reset. */ resetAchievements(): void; /** Playtime evidence — the shell page ticks this while open; the debug button adds minutes at once. */ addPlaytime(seconds: number): void; /** Analytics evidence. Counts only — analytics.track never reaches a shell, so nothing flows here live. */ bumpAnalyticsEvent(eventName: string, count?: number): void; /** The room's flush, by hand: writes the { v, vars } envelope a real room persists, then pokes. */ setMpPlayerVar(field: string, value: number): void; /** * The room's leaderboard submit, by hand: declared boards are written by room rules in production, so * with no room a board a world only READS stays empty forever. Seeds any subject (default the player), * skips the session fences (the room lane has its own, unreachable here), and keeps board shape rules: * creation fixes the order, best-per-subject wins, sorted and trimmed to size. */ seedLeaderboardScore(board: string, score: number, opts?: { subject?: string; displayName?: string; order?: LeaderboardOrder; }): void; /** * The server's hand on the data store: write ANY key, including the ones a session may not touch * (world-global, mp:world:*, the reserved namespaces) — in production those are room/backend-written, * so a world that only READS them sees null locally until something stands in for the writer. Bypasses * the session key policy and the write fences deliberately; criteria evidence re-evaluates at once. */ seedDocument(key: string, value: unknown): void; /** The debug menu's progress print: measured-vs-target for every registered achievement. */ achievementsProgress(): DevShellAchievementProgress[]; private applyProduct; private afterDebugGrant; private titleOf; /** Replace the declared room types. A name the manifest validator would reject is ignored, loudly. */ setRoomTypes(seed: DevShellRoomTypesSeed | undefined): void; /** * `POST /instant-worlds/:worldId/join`, decided but not served: the body table of the join API. * No body / `{}` is the default room. With NO room types declared every typed body is `unknown-room-type`, * which is also what the backend answers while its ROOM_TYPES_ENABLED flag is off. */ joinRoom(body?: DevShellJoinBody): DevShellJoinAnswer; /** * The room's `openRoom` effect, by hand: open (or reuse) an instance of a TICKETED type and mint this * player's ticket to it — the `helix:room-ticket` payload a room would send. Null when the type is not * declared ticketed, which is the same condition under which the real effect is a publish-time error. */ issueRoomTicket(roomType: string, opts?: { instanceId?: string; ttlMs?: number; }): DevShellRoomTicket | null; /** * `POST …/room-instances/:instanceId/rejoin`: a fresh ticket for a player whose first one is spent, while * the instance is open and they hold a ticket row. 404 = no such instance for this player; 410 = closed. */ rejoinRoomInstance(instanceId: string): DevShellRejoinAnswer; /** The ticketed room disposing (it closes its backend instance). Idempotent; false = no such instance. */ closeRoomInstance(instanceId: string): boolean; /** Replace the declared queues. One the manifest validator would reject is ignored, loudly. */ setQueues(seed: DevShellQueuesSeed | undefined): void; /** * The v1 matchmaking / party route table, decided but not served. `path` is the request path with or * without the `/api/v1` prefix; the world id segment is not checked (there is one world here). The caller * is always the simulated player, who is an account — the guest refusals are the SDK's to make. * With NO queues declared every queue route answers 404 `matchmaking-disabled`, which is also what the * backend answers while its MATCHMAKING_ENABLED flag is off. */ platformRequest(method: string, path: string, body?: unknown): DevShellPlatformAnswer; /** * The debug hand standing in for the matcher: the waiting ticket's match forms. Opens an instance of the * queue's ticketed room type and issues this player's room ticket from the simulated room-types table — * the ticket `GET …/queues/tickets/current` then reports as `matched`, redeemable with `joinRoom({ ticket })`. * Null when there is no waiting ticket. */ formMatch(opts?: { ttlMs?: number; }): DevShellRoomTicket | null; /** Debug hand: another player invites the simulated player to their party. Shows up in `GET /parties/current`. */ receivePartyInvite(from?: Partial, opts?: { ttlMs?: number; }): DevShellPartyInvite; /** Debug hand: the player an invite was sent to accepts it and joins the party. False = no such pending invite, or no room. */ acceptPartyInvite(inviteId: string, displayName?: string): boolean; /** * Debug hand: the waiting ticket ends the way the platform would end it — `timeout`, `heartbeat-lapsed`, * `member-absent`, `build-changed`, `queue-removed`, or `replaced` (the player queued in another world). * `GET …/queues/tickets/current` then answers `none` with `ended` for 60 s. False when nothing is waiting. */ endQueueTicket(reason: string): boolean; /** Debug hand: a party member holds a match that still stands (null clears it). Enqueue is then refused naming them. */ setPartyMemberMatch(userId: string | null): void; private closeQueueTicket; /** Debug hand: another player's party has a join code. Returns it, for `POST /parties/join`. */ openPartyCode(host?: Partial, opts?: { ttlMs?: number; }): DevShellPartyCode; /** Debug hand: another player joins the simulated player's party with its code. False = no such live code, or no room. */ usePartyCode(code: string, member?: Partial): boolean; private partyBody; private moveToParty; private currentQueueTicket; private queuedPlayers; private selfMember; private partyChanged; private mintRoomTicket; }