@stage5/lumine 0.2.51 → 0.2.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/constants.js CHANGED
@@ -115,6 +115,8 @@ export const BUNDLED_SDK_REFERENCE_URL = new URL(
115
115
  import.meta.url,
116
116
  );
117
117
  export const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
118
+ export const LUMINE_WORLD_UPDATE_GUIDANCE = `- For Twinkle.world realtime presence, keep render/input loops local. Queue an update only when relevant state changes, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
119
+ - Send Twinkle.world actions only when the discrete action happens; do not poll or automatically retry them. On WORLD_EVENT_RATE_LIMITED or another recoverable non-session-ended error, drop that attempted presence/action update without an immediate retry and keep the session. Reconnect with backoff only after session.ended or Twinkle.world.isSessionEndedError(error).`;
118
120
  export const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
119
121
  # Twinkle Build SDK Reference
120
122
 
@@ -131,6 +133,7 @@ Use these current source-of-truth rules:
131
133
  - Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
132
134
  - Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }. Live web search is enabled by default; pass webSearch: false to disable it for the app.
133
135
  - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
136
+ ${LUMINE_WORLD_UPDATE_GUIDANCE}
134
137
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
135
138
  `;
136
139
  export const LUMINE_THREE_VENDOR_GUIDANCE = `- For Three.js, use the first-party core module: import * as THREE from '${BUILD_VENDOR_THREE_MODULE_IMPORT}';
@@ -285,6 +288,7 @@ lumine save --summary "Describe the change"
285
288
  ${LUMINE_THREE_VENDOR_GUIDANCE}
286
289
  - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
287
290
  - Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
291
+ ${LUMINE_WORLD_UPDATE_GUIDANCE}
288
292
 
289
293
  ## Local Testing (Playwright / browser probes)
290
294
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.51",
3
+ "version": "0.2.52",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.37.0
4
- Updated: 2026-08-25
5
- Generated: 2026-08-25T02:01:15.207Z
3
+ Version: 1.37.2
4
+ Updated: 2026-08-26
5
+ Generated: 2026-08-26T15:17:11.027Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -341,7 +341,7 @@ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
341
341
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
342
342
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
343
343
  const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
344
- - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus } = {}) | scopes: none
344
+ - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
345
345
  - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
346
346
  - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
347
347
  - Signed-in viewers only.
@@ -353,13 +353,14 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
353
353
  - thinkingMode medium uses Grok 4.6 with medium reasoning and consumes normal AI Energy.
354
354
  - thinkingMode high without model uses GPT-5.6 Sol with high reasoning and consumes high AI Energy. Explicit model: 'gpt-5.6-sol' selects Sol with xhigh reasoning at the same High AI Energy tier.
355
355
  - claude-opus-5 uses Anthropic adaptive High thinking. claude-fable-5 uses Anthropic xhigh thinking and normally consumes more AI Energy for comparable token use. Both debit confirmed provider usage at the High tier.
356
- - Pass onStatus and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
356
+ - Pass onStatus, onReasoning, and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
357
+ - onReasoning receives accumulated provider-supplied, app-visible reasoning summaries plus { done, delta, requestId, status }. A provider retry may replace the accumulated summary; treat each callback's first argument as the current source of truth. This callback never exposes hidden/private model chain-of-thought.
357
358
  - onText receives accumulated structured-output text plus { done, delta, requestId, status }. Partial output is intentionally incomplete and may include provider formatting; parse only when done is true, when the callback receives the canonical object serialized as JSON, and use the resolved object as the source of truth.
358
- - Streaming exposes app-visible structured output and high-level phases, not private model chain-of-thought. Put a user-facing field such as producerNotes in expectedStructure when the app should display model-authored commentary from the same generation.
359
+ - Put a user-facing field such as producerNotes in expectedStructure when commentary must be part of the validated final object rather than transient reasoning progress.
359
360
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
360
361
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
361
- - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output. App code should still validate business-specific enum values.
362
- - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
362
+ - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
363
+ - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
363
364
  - onChatStatus(listener) | scopes: none
364
365
  - Returns: unsubscribe function
365
366
  - Listen to shared runtime AI chat stream events.
@@ -742,7 +743,8 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
742
743
  - Signed-in player identity comes from the canonical Twinkle user record; player.profilePicUrl is only used for guests and is returned only when it is a valid absolute HTTPS URL.
743
744
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
744
745
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
745
- - Throttle movement updates in app code, usually 5-15 updates per second. Do not call updatePresence from every animation frame.
746
+ - Treat the render/input loop as local-only. Queue presence only after relevant fields change, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
747
+ - Send discrete actions only when they happen; do not poll or automatically retry them. The parent limits updatePresence and send together to protect the website connection. WORLD_EVENT_RATE_LIMITED is recoverable: drop that attempted update without an immediate retry and keep the current session.
746
748
  - Rooms are addressed by worldKey, roomKey, and instanceId so the contract can later move to sharded or dedicated game backends.
747
749
  - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
748
750
  world.subscribe((event) => updateRemotePlayers(event.players));
@@ -750,8 +752,8 @@ world.updatePresence({ x, y, z, facing });
750
752
  - isRecoverableSessionError(error) | scopes: none
751
753
  - Returns: boolean
752
754
  - Return true when a world request error is expected to be handled by app code instead of crashing.
753
- - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, preview-updating, and timed-out world session requests.
754
- - Only session-ended errors prove that the current handle should be discarded. Timed-out or preview-updating presence requests can be dropped without reconnecting.
755
+ - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, rate-limited, preview-updating, and timed-out world session requests.
756
+ - Only session-ended errors prove that the current handle should be discarded. WORLD_EVENT_RATE_LIMITED, timed-out, or preview-updating presence/action requests must be dropped without reconnecting and without an immediate retry.
755
757
  - For durable game state, write through sharedDb/privateDb instead of relying on world presence — but LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick).
756
758
  - Example: try {
757
759
  await world.updatePresence({ x, y, z, facing });
@@ -1028,59 +1030,104 @@ Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtim
1028
1030
 
1029
1031
  ```js
1030
1032
  let world = null;
1033
+ let worldConnection = null;
1031
1034
  let reconnectTimer = 0;
1035
+ let reconnectDelayMs = 1000;
1036
+ let latestPresence = { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' };
1037
+ let latestPresenceKey = JSON.stringify(latestPresence);
1038
+ let queuedPresence = null;
1039
+ let presenceInFlight = false;
1032
1040
 
1033
1041
  async function connectWorld() {
1034
1042
  if (world) return world;
1035
- world = await Twinkle.world.join({
1036
- worldKey: 'town',
1037
- roomKey: 'square',
1038
- presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
1043
+ if (worldConnection) return worldConnection;
1044
+ const presenceAtJoin = latestPresence;
1045
+ const presenceKeyAtJoin = latestPresenceKey;
1046
+ worldConnection = Twinkle.world.join({
1047
+ worldKey: 'town', roomKey: 'square', presence: presenceAtJoin,
1039
1048
  player: { name: avatarName }
1040
1049
  });
1050
+ try {
1051
+ const session = await worldConnection;
1052
+ world = session;
1053
+ reconnectDelayMs = 1000;
1054
+ session.subscribe((event) => {
1055
+ renderPlayers(event.players);
1056
+ if (event.type === 'session.ended') handleWorldDrop(session);
1057
+ if (event.type === 'action.received' && event.action?.type === 'emote') {
1058
+ showEmote(event.sessionId, event.action.data.emote);
1059
+ }
1060
+ });
1061
+ if (latestPresenceKey !== presenceKeyAtJoin) queuedPresence = latestPresence;
1062
+ return session;
1063
+ } finally {
1064
+ worldConnection = null;
1065
+ }
1066
+ }
1041
1067
 
1042
- world.subscribe((event) => {
1043
- renderPlayers(event.players);
1044
- if (event.type === 'session.ended') {
1045
- handleWorldDrop();
1046
- }
1047
- if (event.type === 'action.received' && event.action?.type === 'emote') {
1048
- showEmote(event.sessionId, event.action.data.emote);
1049
- }
1050
- });
1051
- return world;
1068
+ function handleWorldConnectError(error) {
1069
+ if (Twinkle.world.isRecoverableSessionError(error)) {
1070
+ scheduleReconnect();
1071
+ } else {
1072
+ console.error('World connection failed', error);
1073
+ }
1074
+ }
1075
+
1076
+ function scheduleReconnect() {
1077
+ if (reconnectTimer || world || worldConnection) return;
1078
+ const delay = reconnectDelayMs;
1079
+ reconnectDelayMs = Math.min(30000, reconnectDelayMs * 2);
1080
+ reconnectTimer = setTimeout(() => {
1081
+ reconnectTimer = 0;
1082
+ connectWorld().catch(handleWorldConnectError);
1083
+ }, delay);
1052
1084
  }
1053
1085
 
1054
- function handleWorldDrop() {
1086
+ function handleWorldDrop(session = world) {
1087
+ if (session && world && world !== session) return;
1055
1088
  world = null;
1056
- if (!reconnectTimer) {
1057
- reconnectTimer = setTimeout(() => {
1058
- reconnectTimer = 0;
1059
- connectWorld().catch(handleWorldDrop);
1060
- }, 1000);
1061
- }
1089
+ queuedPresence = null;
1090
+ scheduleReconnect();
1091
+ }
1092
+
1093
+ function queuePresence(next) {
1094
+ const key = JSON.stringify(next);
1095
+ if (key === latestPresenceKey) return;
1096
+ latestPresenceKey = key;
1097
+ latestPresence = next;
1098
+ if (world) queuedPresence = next; // Coalesce to the newest unsent snapshot.
1062
1099
  }
1063
1100
 
1064
- async function syncPresence() {
1101
+ async function flushPresence() {
1102
+ if (presenceInFlight || !queuedPresence || !world) return;
1103
+ const session = world;
1104
+ const next = queuedPresence;
1105
+ queuedPresence = null;
1106
+ presenceInFlight = true;
1065
1107
  try {
1066
- const session = await connectWorld();
1067
- // Throttle this in the game loop, for example 5-15 times per second.
1068
- await session.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
1108
+ await session.updatePresence(next);
1069
1109
  } catch (error) {
1070
1110
  if (Twinkle.world.isSessionEndedError(error)) {
1071
- handleWorldDrop();
1072
- return;
1111
+ handleWorldDrop(session);
1112
+ } else if (!Twinkle.world.isRecoverableSessionError(error)) {
1113
+ console.error('World update failed', error);
1073
1114
  }
1074
- if (Twinkle.world.isRecoverableSessionError(error)) {
1075
- // Drop this transient presence update and keep the current handle.
1076
- return;
1077
- }
1078
- throw error;
1115
+ // Recoverable errors drop this transient snapshot without an immediate retry.
1116
+ } finally {
1117
+ presenceInFlight = false;
1079
1118
  }
1080
1119
  }
1081
1120
 
1082
- await connectWorld();
1083
- await syncPresence();
1121
+ // The render/input loop only queues changed local state.
1122
+ function onPlayerStateChanged() {
1123
+ queuePresence({
1124
+ x: player.x, y: player.y, z: player.z,
1125
+ facing, animation: player.animation
1126
+ });
1127
+ }
1128
+
1129
+ connectWorld().catch(handleWorldConnectError);
1130
+ setInterval(() => { void flushPresence(); }, 100); // Fixed 10 Hz cap.
1084
1131
  ```
1085
1132
 
1086
1133
  ### Play chess against the computer