@playcademy/vite-plugin 1.2.1-beta.3 → 1.2.1-beta.5

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/dist/index.js CHANGED
@@ -23757,6 +23757,7 @@ var POSTHOG_CONFIG = {
23757
23757
  uiHost: "https://us.posthog.com"
23758
23758
  };
23759
23759
  // ../constants/src/timeback.ts
23760
+ var VALID_E_LEVELS = ["E1", "E2", "E3", "E4"];
23760
23761
  var TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY";
23761
23762
  var TIMEBACK_ORG_NAME = "Playcademy Studios";
23762
23763
  var TIMEBACK_ORG_TYPE = "department";
@@ -24314,7 +24315,7 @@ import path2 from "node:path";
24314
24315
  // package.json
24315
24316
  var package_default = {
24316
24317
  name: "@playcademy/vite-plugin",
24317
- version: "1.2.1-beta.3",
24318
+ version: "1.2.1-beta.5",
24318
24319
  type: "module",
24319
24320
  exports: {
24320
24321
  ".": {
@@ -24347,7 +24348,7 @@ var package_default = {
24347
24348
  "@types/bun": "1.3.5"
24348
24349
  },
24349
24350
  peerDependencies: {
24350
- "@playcademy/sdk": ">=0.12.0",
24351
+ "@playcademy/sdk": ">=0.17.0",
24351
24352
  typescript: "^5 || ^6",
24352
24353
  vite: "^5 || ^6 || ^7 || ^8"
24353
24354
  }
@@ -24635,6 +24636,7 @@ var serverState = {
24635
24636
  dashboardApp: null,
24636
24637
  viteServer: null,
24637
24638
  currentMode: "platform",
24639
+ parentOptions: null,
24638
24640
  timebackRoleOverride: null,
24639
24641
  platformRoleOverride: null
24640
24642
  };
@@ -24647,6 +24649,12 @@ function hasActiveServers() {
24647
24649
  function getCurrentMode() {
24648
24650
  return serverState.currentMode;
24649
24651
  }
24652
+ function getParentOptions() {
24653
+ return serverState.parentOptions;
24654
+ }
24655
+ function setParentOptions(parent) {
24656
+ serverState.parentOptions = parent;
24657
+ }
24650
24658
  function setCurrentMode(mode) {
24651
24659
  serverState.currentMode = mode;
24652
24660
  }
@@ -24703,14 +24711,31 @@ import path5 from "node:path";
24703
24711
  import { CONFIG_FILE_NAMES } from "playcademy/constants";
24704
24712
 
24705
24713
  // src/server/modes.ts
24714
+ function assertChildParentOptions(parent) {
24715
+ if (!parent) {
24716
+ throw new Error("[Playcademy] mode: 'child' requires the `parent` option, e.g. parent: { intent: { lessonId: 'my-lesson', eLevel: 'E2' } }");
24717
+ }
24718
+ if (!parent.intent) {
24719
+ throw new Error("[Playcademy] mode: 'child' requires `parent.intent`: the child game boots straight into the lesson it names");
24720
+ }
24721
+ if (!parent.intent.lessonId) {
24722
+ throw new Error("[Playcademy] `parent.intent.lessonId` is required and must be non-empty");
24723
+ }
24724
+ if (!parent.intent.eLevel || !VALID_E_LEVELS.includes(parent.intent.eLevel)) {
24725
+ throw new Error("[Playcademy] `parent.intent.eLevel` is required, one of 'E1' | 'E2' | 'E3' | 'E4'");
24726
+ }
24727
+ }
24706
24728
  function isShellMode(mode) {
24707
24729
  return mode !== "standalone";
24708
24730
  }
24709
- function getNextMode(mode) {
24731
+ function getNextMode(mode, includeChild) {
24710
24732
  if (mode === "platform") {
24711
24733
  return "demo";
24712
24734
  }
24713
24735
  if (mode === "demo") {
24736
+ return includeChild ? "child" : "standalone";
24737
+ }
24738
+ if (mode === "child") {
24714
24739
  return "standalone";
24715
24740
  }
24716
24741
  return "platform";
@@ -25887,7 +25912,7 @@ var package_default2;
25887
25912
  var init_package = __esm(() => {
25888
25913
  package_default2 = {
25889
25914
  name: "@playcademy/sandbox",
25890
- version: "0.7.1-beta.3",
25915
+ version: "0.7.1-beta.5",
25891
25916
  description: "Local development server for Playcademy game development",
25892
25917
  type: "module",
25893
25918
  exports: {
@@ -48521,6 +48546,7 @@ var init_d1 = __esm(() => {
48521
48546
  var init_kv = () => {};
48522
48547
  var RESERVED_SUBDOMAINS;
48523
48548
  var init_hostname = __esm(() => {
48549
+ init_src();
48524
48550
  RESERVED_SUBDOMAINS = new Set(["www", "hub", "cdn", "realtime", "admin", "docs", "api"]);
48525
48551
  });
48526
48552
  var init_mime = () => {};
@@ -50747,9 +50773,9 @@ var init_helpers3 = __esm(() => {
50747
50773
  init_mime();
50748
50774
  });
50749
50775
  var init_provider = __esm(() => {
50750
- init_src();
50751
50776
  init_src();
50752
50777
  init_core();
50778
+ init_hostname();
50753
50779
  init_constants2();
50754
50780
  init_assets_config();
50755
50781
  init_bindings();
@@ -181513,10 +181539,24 @@ var shell_default2 = `<!doctype html>
181513
181539
  height: 100vh;
181514
181540
  overflow: hidden;
181515
181541
  }
181516
- #badge {
181517
- position: fixed;
181542
+ .corner-top-left {
181543
+ top: 0.5rem;
181544
+ left: 0.5rem;
181545
+ }
181546
+ .corner-top-right {
181518
181547
  top: 0.5rem;
181548
+ right: 0.5rem;
181549
+ }
181550
+ .corner-bottom-left {
181551
+ bottom: 0.5rem;
181519
181552
  left: 0.5rem;
181553
+ }
181554
+ .corner-bottom-right {
181555
+ bottom: 0.5rem;
181556
+ right: 0.5rem;
181557
+ }
181558
+ #badge {
181559
+ position: fixed;
181520
181560
  background: rgba(23, 23, 23, 0.9);
181521
181561
  color: white;
181522
181562
  padding: 0.25rem 0.5rem;
@@ -181531,6 +181571,28 @@ var shell_default2 = `<!doctype html>
181531
181571
  #badge.offline {
181532
181572
  background: rgba(211, 47, 47, 0.9);
181533
181573
  }
181574
+ #relay {
181575
+ position: fixed;
181576
+ background: rgba(23, 23, 23, 0.9);
181577
+ color: #e0e0e0;
181578
+ padding: 0.5rem 0.75rem;
181579
+ border-radius: 4px;
181580
+ font-family: 'VT323', monospace;
181581
+ font-size: 1rem;
181582
+ z-index: 9999;
181583
+ backdrop-filter: blur(10px);
181584
+ letter-spacing: 0.05em;
181585
+ max-width: 26rem;
181586
+ }
181587
+ #relay .dim {
181588
+ color: #9e9e9e;
181589
+ }
181590
+ #relay-report {
181591
+ color: #7cfc98;
181592
+ }
181593
+ #relay-report.ignored {
181594
+ color: #f87171;
181595
+ }
181534
181596
  #frame {
181535
181597
  position: absolute;
181536
181598
  inset: 0;
@@ -181544,11 +181606,26 @@ var shell_default2 = `<!doctype html>
181544
181606
  </style>
181545
181607
  </head>
181546
181608
  <body>
181547
- <div id="badge">PLAYCADEMY</div>
181609
+ <div id="badge" class="corner-{{BADGE_POSITION}}">PLAYCADEMY</div>
181610
+ <div id="relay" class="hidden corner-{{RELAY_PANEL_POSITION}}">
181611
+ <div id="relay-intent"></div>
181612
+ <div id="relay-time" class="dim">active 0s / paused 0s</div>
181613
+ <div id="relay-report"></div>
181614
+ </div>
181548
181615
  <iframe id="frame" class="hidden"></iframe>
181549
181616
 
181550
181617
  <script type="module">
181551
- const { MessageEvents, messaging } = await import('/@playcademy/shell-sdk')
181618
+ const {
181619
+ MessageEvents,
181620
+ messaging,
181621
+ HANDSHAKE_RESEND_INTERVAL_MS,
181622
+ HANDSHAKE_MAX_DURATION_MS,
181623
+ SDK_SUPPORTS_CHILD_MODE,
181624
+ } = await import('/@playcademy/shell-sdk')
181625
+
181626
+ // ============================================================
181627
+ // Templated config (filled in by the dev server middleware)
181628
+ // ============================================================
181552
181629
 
181553
181630
  const CONFIG = {
181554
181631
  sandboxUrl: '{{SANDBOX_URL}}',
@@ -181556,27 +181633,149 @@ var shell_default2 = `<!doctype html>
181556
181633
  gameToken: '{{GAME_TOKEN}}',
181557
181634
  gameUrl: '{{GAME_URL}}' || undefined,
181558
181635
  mode: '{{MODE}}',
181559
- timebackJson: '{{TIMEBACK_DATA}}',
181636
+ timebackJson: {{TIMEBACK_DATA}},
181637
+ parentJson: {{PARENT_DATA}},
181560
181638
  hideBadge: '{{HIDE_BADGE}}' === 'true',
181639
+ hideRelayPanel: '{{HIDE_RELAY_PANEL}}' === 'true',
181561
181640
  }
181562
181641
 
181563
- const timeback = (() => {
181642
+ function parseJsonOrNull(raw) {
181564
181643
  try {
181565
- return JSON.parse(CONFIG.timebackJson)
181644
+ return JSON.parse(raw)
181566
181645
  } catch {
181567
181646
  return null
181568
181647
  }
181569
- })()
181648
+ }
181570
181649
 
181571
- const badge = document.getElementById('badge')
181572
- const frame = document.getElementById('frame')
181650
+ const timeback = parseJsonOrNull(CONFIG.timebackJson)
181651
+ const parentContext = parseJsonOrNull(CONFIG.parentJson)
181573
181652
 
181574
- if (CONFIG.hideBadge) {
181575
- badge.classList.add('hidden')
181653
+ if (CONFIG.mode === 'child' && !SDK_SUPPORTS_CHILD_MODE) {
181654
+ console.error(
181655
+ "[Playcademy] mode 'child' needs an @playcademy/sdk that ships client.parent (>= 0.16.0); the project's installed SDK predates it. Upgrade the SDK dependency.",
181656
+ )
181576
181657
  }
181577
181658
 
181659
+ // ============================================================
181660
+ // DOM + logging
181661
+ // ============================================================
181662
+
181663
+ const badge = document.getElementById('badge')
181664
+ const frame = document.getElementById('frame')
181665
+
181578
181666
  const log = (...args) => window.PLAYCADEMY_DEBUG && console.log('[DevShell]', ...args)
181579
181667
 
181668
+ // ============================================================
181669
+ // Child-mode relay panel
181670
+ // ============================================================
181671
+ // In child mode the shell plays the parent game, and the game's
181672
+ // relayed messages replace every backend POST it would otherwise
181673
+ // make - they ARE the integration's output, so the panel shows
181674
+ // them: the opened run, live timing, and the final report.
181675
+
181676
+ const relayPanel = (() => {
181677
+ const root = document.getElementById('relay')
181678
+ const els = {
181679
+ intent: document.getElementById('relay-intent'),
181680
+ time: document.getElementById('relay-time'),
181681
+ report: document.getElementById('relay-report'),
181682
+ }
181683
+
181684
+ // Each relay message is a cumulative snapshot of ONE window
181685
+ // (keyed by windowStartedAtMs), and the tracker rotates
181686
+ // windows on its 15s accounting cadence and on tab-hide.
181687
+ // Keep the latest snapshot per window; sum the windows.
181688
+ const windows = new Map()
181689
+ let sawBeat = false
181690
+ let reported = false
181691
+
181692
+ const isTrackerRelay = type =>
181693
+ type === MessageEvents.TIMEBACK_ACTIVITY_START ||
181694
+ type === MessageEvents.TIMEBACK_HEARTBEAT_RELAY ||
181695
+ type === MessageEvents.TIMEBACK_ACTIVITY_END
181696
+
181697
+ function show(intent) {
181698
+ const { lessonId, eLevel } = intent ?? {}
181699
+ els.intent.textContent = \`intent: \${lessonId}\${eLevel ? \` (\${eLevel})\` : ''}\`
181700
+ root.classList.remove('hidden')
181701
+ }
181702
+
181703
+ function onMessage(type, payload) {
181704
+ if (type === MessageEvents.READY) {
181705
+ // A reloaded game document is a fresh launch.
181706
+ reported = false
181707
+ windows.clear()
181708
+ els.report.textContent = ''
181709
+ els.report.classList.remove('ignored')
181710
+ return
181711
+ }
181712
+
181713
+ // One report per launch, exactly like a production
181714
+ // parent: after the report the books are closed, so a
181715
+ // replay loop fails here, at the developer's desk.
181716
+ if (reported && isTrackerRelay(type)) {
181717
+ els.report.textContent = '⚠ ignored: already reported (one report per launch)'
181718
+ els.report.classList.add('ignored')
181719
+ console.error(
181720
+ '[DevShell] this launch already reported; ignoring tracker relays, exactly as a parent game would. One report per launch: report once, then exit.',
181721
+ )
181722
+ return
181723
+ }
181724
+
181725
+ if (type === MessageEvents.TIMEBACK_ACTIVITY_START) {
181726
+ // A fresh run's timing must not sum with the last
181727
+ // run's windows.
181728
+ windows.clear()
181729
+ console.log('[DevShell] child relay: activity start', payload)
181730
+ }
181731
+
181732
+ if (type === MessageEvents.TIMEBACK_HEARTBEAT_RELAY) {
181733
+ const key = payload?.windowStartedAtMs
181734
+
181735
+ if (typeof key === 'number') {
181736
+ windows.set(key, {
181737
+ activeMs: payload?.timingData?.activeMs ?? 0,
181738
+ pausedMs: payload?.timingData?.pausedMs ?? 0,
181739
+ })
181740
+ }
181741
+
181742
+ let activeMs = 0
181743
+ let pausedMs = 0
181744
+
181745
+ for (const w of windows.values()) {
181746
+ activeMs += w.activeMs
181747
+ pausedMs += w.pausedMs
181748
+ }
181749
+
181750
+ els.time.textContent = \`active \${Math.round(activeMs / 1000)}s / paused \${Math.round(pausedMs / 1000)}s\`
181751
+
181752
+ // Dim zeros that never brighten mean the game never
181753
+ // called startActivity().
181754
+ if (!sawBeat) {
181755
+ sawBeat = true
181756
+ els.time.classList.remove('dim')
181757
+ }
181758
+ }
181759
+
181760
+ if (type === MessageEvents.TIMEBACK_ACTIVITY_END) {
181761
+ const score = payload?.scoreData
181762
+ // durationSeconds is the FULL sitting;
181763
+ // sessionTimingData is only the post-flush remainder
181764
+ // (near zero by design).
181765
+ const seconds = payload?.timingData?.durationSeconds
181766
+ els.report.textContent = \`report: \${score?.correctQuestions}/\${score?.totalQuestions} correct, xp \${payload?.xpEarned ?? 'n/a'}, \${seconds}s (full report in console)\`
181767
+ console.log('[DevShell] child relay: end-activity report', payload)
181768
+ reported = true
181769
+ }
181770
+ }
181771
+
181772
+ return { show, onMessage }
181773
+ })()
181774
+
181775
+ // ============================================================
181776
+ // Sandbox liveness
181777
+ // ============================================================
181778
+
181580
181779
  async function checkSandbox() {
181581
181780
  // Use the public /health endpoint as a pure liveness probe.
181582
181781
  // We used to hit /api/users/me here, but that's \`requireNonAnonymous\`
@@ -181597,14 +181796,25 @@ var shell_default2 = `<!doctype html>
181597
181796
  }
181598
181797
  }
181599
181798
 
181600
- function initHandshake(timebackData) {
181799
+ // ============================================================
181800
+ // INIT handshake (shell → game)
181801
+ // ============================================================
181802
+
181803
+ function initHandshake() {
181601
181804
  const payload = {
181602
181805
  baseUrl: CONFIG.sandboxUrl,
181603
181806
  gameUrl: CONFIG.gameUrl,
181604
181807
  gameId: CONFIG.gameId,
181605
181808
  token: CONFIG.gameToken,
181606
181809
  mode: CONFIG.mode,
181607
- timeback: timebackData,
181810
+ // A production parent never forwards its own timeback
181811
+ // user context to a child; mirror that omission.
181812
+ timeback: CONFIG.mode === 'child' ? undefined : timeback,
181813
+ parent: parentContext ?? undefined,
181814
+ // Child embedders consume the relay stream (the panel
181815
+ // below), so the game's tracker must not beacon its own
181816
+ // backend on unload — same flag embed.launch() sets.
181817
+ hasHeartbeatRelay: CONFIG.mode === 'child' ? true : undefined,
181608
181818
  }
181609
181819
 
181610
181820
  let interval, timeout
@@ -181627,11 +181837,14 @@ var shell_default2 = `<!doctype html>
181627
181837
  frame.classList.remove('hidden')
181628
181838
  log('Frame loaded, starting handshake')
181629
181839
  send()
181630
- interval = setInterval(send, 300)
181840
+ interval = setInterval(send, HANDSHAKE_RESEND_INTERVAL_MS)
181841
+ // The SDK's constant exceeds the game's 25s INIT wait so
181842
+ // a struggling game can send INIT_ERROR before the shell
181843
+ // gives up.
181631
181844
  timeout = setTimeout(() => {
181632
181845
  stop()
181633
181846
  log('Handshake timeout')
181634
- }, 10000)
181847
+ }, HANDSHAKE_MAX_DURATION_MS)
181635
181848
  }
181636
181849
 
181637
181850
  messaging.listen(MessageEvents.READY, () => {
@@ -181642,29 +181855,67 @@ var shell_default2 = `<!doctype html>
181642
181855
  frame.src = '/'
181643
181856
  }
181644
181857
 
181645
- window.addEventListener('message', e => {
181646
- if (e.source !== frame.contentWindow) return
181647
- const { type, ...payload } = e.data || {}
181648
- if (type?.startsWith('PLAYCADEMY_')) {
181649
- log('Message:', type, payload)
181650
- messaging.send(type, payload)
181858
+ // ============================================================
181859
+ // Game shell message routing
181860
+ // ============================================================
181861
+ // The wire shape is { type, payload }. Everything the game posts
181862
+ // gets logged and re-dispatched locally so shell-side listeners
181863
+ // (messaging.listen) can consume it; in child mode the relay
181864
+ // panel also gets each payload.
181865
+
181866
+ window.addEventListener('message', event => {
181867
+ if (event.source !== frame.contentWindow) return
181868
+
181869
+ const { type, payload } = event.data || {}
181870
+ if (!type?.startsWith('PLAYCADEMY_')) return
181871
+
181872
+ log('Message:', type, payload)
181873
+
181874
+ if (CONFIG.mode === 'child') {
181875
+ relayPanel.onMessage(type, payload)
181651
181876
  }
181877
+
181878
+ messaging.send(type, payload)
181652
181879
  })
181653
181880
 
181654
- checkSandbox().then(() => initHandshake(timeback))
181881
+ // ============================================================
181882
+ // Boot
181883
+ // ============================================================
181884
+
181885
+ if (CONFIG.hideBadge) {
181886
+ badge.classList.add('hidden')
181887
+ }
181888
+
181889
+ if (CONFIG.mode === 'child' && parentContext) {
181890
+ badge.textContent = 'PLAYCADEMY CHILD'
181891
+
181892
+ if (!CONFIG.hideRelayPanel) {
181893
+ relayPanel.show(parentContext.intent)
181894
+ }
181895
+ }
181896
+
181897
+ checkSandbox().then(() => initHandshake())
181655
181898
  </script>
181656
181899
  </body>
181657
181900
  </html>
181658
181901
  `;
181659
181902
 
181660
181903
  // src/server/middleware.ts
181904
+ function jsonScriptLiteral(json6) {
181905
+ return JSON.stringify(json6).replace(/</g, String.raw`\u003c`);
181906
+ }
181661
181907
  function generateLoaderHTML(sandboxUrl, gameSlug, gameId, options, gameUrl) {
181662
181908
  const timebackJson = generateTimebackJson(options.timeback);
181663
181909
  const mode = getCurrentMode();
181910
+ const parentOptions = getParentOptions();
181911
+ const parentJson = mode === "child" && parentOptions ? JSON.stringify({
181912
+ gameId: parentOptions.gameId ?? "dev-parent-game",
181913
+ intent: parentOptions.intent
181914
+ }) : "null";
181664
181915
  const tokenIdentity = mode === "demo" ? "anonymous" : getPlatformRoleOverride() ?? "player";
181665
181916
  const gameToken = createSandboxGameToken(gameSlug, tokenIdentity);
181666
181917
  const handshakeGameId = gameId || gameSlug;
181667
- return shell_default2.replace(/{{SANDBOX_URL}}/g, sandboxUrl).replace(/{{GAME_ID}}/g, handshakeGameId).replace(/{{GAME_TOKEN}}/g, gameToken).replace(/{{GAME_URL}}/g, gameUrl || "").replace(/{{MODE}}/g, isShellMode(mode) ? mode : "platform").replace(/{{TIMEBACK_DATA}}/g, timebackJson).replace(/{{HIDE_BADGE}}/g, String(options.hideBadge));
181918
+ return shell_default2.replace(/{{SANDBOX_URL}}/g, () => sandboxUrl).replace(/{{GAME_ID}}/g, () => handshakeGameId).replace(/{{GAME_TOKEN}}/g, () => gameToken).replace(/{{GAME_URL}}/g, () => gameUrl || "").replace(/{{MODE}}/g, () => isShellMode(mode) ? mode : "platform").replace(/{{TIMEBACK_DATA}}/g, () => jsonScriptLiteral(timebackJson)).replace(/{{PARENT_DATA}}/g, () => jsonScriptLiteral(parentJson)).replace(/{{HIDE_BADGE}}/g, () => String(options.display.hideBadge)).replace(/{{BADGE_POSITION}}/g, () => options.display.badgePosition).replace(/{{HIDE_RELAY_PANEL}}/g, () => String(options.display.hideRelayPanel)).replace(/{{RELAY_PANEL_POSITION}}/g, () => options.display.relayPanelPosition);
181668
181919
  }
181669
181920
  function devServerMiddleware(server, sandbox, gameUrl, options) {
181670
181921
  server.middlewares.use("/", (req, res, next) => {
@@ -181723,7 +181974,7 @@ async function recreateSandbox(options) {
181723
181974
  if (sandbox.project && serverState.gameBackend) {
181724
181975
  const gameUrl = `http://localhost:${serverState.gameBackend.port}`;
181725
181976
  devServerMiddleware(viteServer, sandbox, gameUrl, {
181726
- hideBadge: platformModeOptions.hideBadge,
181977
+ display: platformModeOptions.display,
181727
181978
  timeback: timebackDisabled ? undefined : {
181728
181979
  baseCourses: sandbox.project.timebackCourses ?? [],
181729
181980
  overrides: timebackOptions
@@ -182028,7 +182279,7 @@ async function configurePlatformMode(server, viteConfig, options) {
182028
182279
  overrides: timebackOptions
182029
182280
  };
182030
182281
  devServerMiddleware(server, sandbox, gameUrl, {
182031
- hideBadge: options.hideBadge,
182282
+ display: options.display,
182032
182283
  timeback: timebackContext
182033
182284
  });
182034
182285
  }
@@ -182080,13 +182331,21 @@ async function configureStandaloneMode(server, viteConfig, options) {
182080
182331
  // src/server/hotkeys/toggle-mode.ts
182081
182332
  async function toggleMode(options) {
182082
182333
  const currentMode = getCurrentMode();
182083
- const newMode = getNextMode(currentMode);
182334
+ let newMode = getNextMode(currentMode, getParentOptions() !== null);
182084
182335
  const viteServer = getViteServerRef();
182085
182336
  const prefix2 = createLogPrefix("playcademy");
182086
182337
  if (!viteServer) {
182087
182338
  options.viteConfig.logger.error(`${prefix2} ${import_picocolors16.red("Cannot toggle mode: no Vite server reference")}`);
182088
182339
  return;
182089
182340
  }
182341
+ if (newMode === "child") {
182342
+ try {
182343
+ assertChildParentOptions(getParentOptions());
182344
+ } catch (error89) {
182345
+ options.viteConfig.logger.error(`${prefix2} ${import_picocolors16.red(errorMessage(error89))}`);
182346
+ newMode = getNextMode(newMode, false);
182347
+ }
182348
+ }
182090
182349
  await cleanupServers();
182091
182350
  await new Promise((resolve2) => setTimeout(resolve2, 100));
182092
182351
  setCurrentMode(newMode);
@@ -182103,7 +182362,7 @@ async function toggleMode(options) {
182103
182362
  function toggleModeHotkey(options) {
182104
182363
  return {
182105
182364
  key: "m",
182106
- description: `${import_picocolors16.cyan(import_picocolors16.bold("[playcademy]"))} cycle platform/demo/standalone mode`,
182365
+ description: `${import_picocolors16.cyan(import_picocolors16.bold("[playcademy]"))} cycle dev mode`,
182107
182366
  action: () => toggleMode(options)
182108
182367
  };
182109
182368
  }
@@ -182144,8 +182403,12 @@ async function configureServerHook(server, context) {
182144
182403
  if (!context.viteConfig) {
182145
182404
  throw new Error("[Playcademy] Vite config not resolved before configureServer");
182146
182405
  }
182406
+ if (context.options.mode === "child") {
182407
+ assertChildParentOptions(context.options.parent);
182408
+ }
182147
182409
  setViteServerRef(server);
182148
182410
  setCurrentMode(context.options.mode);
182411
+ setParentOptions(context.options.parent ?? null);
182149
182412
  setupProcessShutdownHandlers();
182150
182413
  await registerAssetMiddleware(server, context.viteConfig.root, context.options.configPath);
182151
182414
  if (hasActiveServers()) {
@@ -182163,7 +182426,7 @@ async function configureServerHook(server, context) {
182163
182426
  seed: context.options.seed,
182164
182427
  memoryOnly: context.options.memoryOnly,
182165
182428
  databasePath: context.options.databasePath,
182166
- hideBadge: context.options.hideBadge,
182429
+ display: context.options.display,
182167
182430
  gameBackendPort: context.options.gameBackendPort,
182168
182431
  configPath: context.options.configPath,
182169
182432
  timeback: context.options.timeback
@@ -182192,7 +182455,14 @@ function resolveShellSdkId(id) {
182192
182455
  }
182193
182456
  function loadShellSdk(id) {
182194
182457
  if (id === SHELL_SDK_ID) {
182195
- return `export { MessageEvents, messaging } from '@playcademy/sdk'`;
182458
+ return [
182459
+ `export { MessageEvents, messaging } from '@playcademy/sdk'`,
182460
+ `import * as internal from '@playcademy/sdk/internal'`,
182461
+ `export const HANDSHAKE_RESEND_INTERVAL_MS = internal.HANDSHAKE_RESEND_INTERVAL_MS ?? 300`,
182462
+ `export const HANDSHAKE_MAX_DURATION_MS = internal.HANDSHAKE_MAX_DURATION_MS ?? 30000`,
182463
+ `export const SDK_SUPPORTS_CHILD_MODE = internal.HANDSHAKE_MAX_DURATION_MS !== undefined`
182464
+ ].join(`
182465
+ `);
182196
182466
  }
182197
182467
  }
182198
182468
 
@@ -182265,6 +182535,7 @@ function resolveOptions(options) {
182265
182535
  return {
182266
182536
  configPath: options.configPath,
182267
182537
  mode: options.mode ?? "platform",
182538
+ parent: options.parent,
182268
182539
  gameBackendPort: options.gameBackendPort ?? DEFAULT_PORTS4.GAME,
182269
182540
  sandboxPort,
182270
182541
  dashboardAppPort: options.dashboardAppPort ?? DEFAULT_PORTS4.DASHBOARD_APP,
@@ -182278,7 +182549,12 @@ function resolveOptions(options) {
182278
182549
  seed: sandboxOptions.seed ?? true,
182279
182550
  memoryOnly: sandboxOptions.memoryOnly ?? false,
182280
182551
  databasePath: sandboxOptions.databasePath,
182281
- hideBadge: displayOptions.hideBadge ?? false,
182552
+ display: {
182553
+ hideBadge: displayOptions.hideBadge ?? false,
182554
+ badgePosition: displayOptions.badgePosition ?? "top-left",
182555
+ hideRelayPanel: displayOptions.hideRelayPanel ?? false,
182556
+ relayPanelPosition: displayOptions.relayPanelPosition ?? "bottom-left"
182557
+ },
182282
182558
  timeback: options.timeback
182283
182559
  };
182284
182560
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 'm' hotkey - cycle platform/demo/standalone mode
2
+ * 'm' hotkey - cycle platform/demo(/child)/standalone mode
3
3
  */
4
4
  import type { HotkeyOptions } from '../../types';
5
5
  export declare function toggleModeHotkey(options: HotkeyOptions): {
@@ -1,7 +1,8 @@
1
1
  import type { ViteDevServer } from 'vite';
2
- import type { SandboxManager, TimebackPluginContext } from '../types';
2
+ import type { ResolvedDisplayOptions, SandboxManager, TimebackPluginContext } from '../types';
3
3
  export interface ShellOptions {
4
- hideBadge: boolean;
4
+ display: ResolvedDisplayOptions;
5
5
  timeback?: TimebackPluginContext;
6
6
  }
7
+ export declare function generateLoaderHTML(sandboxUrl: string, gameSlug: string, gameId: string, options: ShellOptions, gameUrl?: string): string;
7
8
  export declare function devServerMiddleware(server: ViteDevServer, sandbox: SandboxManager, gameUrl: string | undefined, options: ShellOptions): void;
@@ -1,4 +1,17 @@
1
- import type { PlaycademyMode } from '../types/options';
1
+ import type { PlaycademyMode, PlaycademyParentOptions } from '../types/options';
2
2
  export type ShellMode = Exclude<PlaycademyMode, 'standalone'>;
3
+ /**
4
+ * Validates the `parent` plugin option for child mode. TypeScript users
5
+ * cannot get this wrong, but a plain-JS vite config can pass a hollow
6
+ * object; without this check the failure surfaces as an unrelated
7
+ * TypeError inside the child game, nowhere near the config that caused
8
+ * it.
9
+ */
10
+ export declare function assertChildParentOptions(parent: PlaycademyParentOptions | undefined | null): void;
3
11
  export declare function isShellMode(mode: PlaycademyMode): mode is ShellMode;
4
- export declare function getNextMode(mode: PlaycademyMode): PlaycademyMode;
12
+ /**
13
+ * The `m` hotkey's cycle. `'child'` joins it only when a parent context is
14
+ * configured: cycling a game into child mode with no intent to deliver
15
+ * would boot it into a meaningless state.
16
+ */
17
+ export declare function getNextMode(mode: PlaycademyMode, includeChild: boolean): PlaycademyMode;
@@ -9,7 +9,7 @@
9
9
  import type { ViteDevServer } from 'vite';
10
10
  import type { DashboardAppManager } from '../lib/dashboard-app';
11
11
  import type { GameBackendServerManager, PlatformRoleOverride, SandboxManager, TimebackRoleOverride } from '../types';
12
- import type { PlaycademyMode } from '../types/options';
12
+ import type { PlaycademyMode, PlaycademyParentOptions } from '../types/options';
13
13
  /**
14
14
  * Module-level server references
15
15
  */
@@ -19,6 +19,7 @@ export declare const serverState: {
19
19
  dashboardApp: DashboardAppManager | null;
20
20
  viteServer: ViteDevServer | null;
21
21
  currentMode: PlaycademyMode;
22
+ parentOptions: PlaycademyParentOptions | null;
22
23
  timebackRoleOverride: TimebackRoleOverride | null;
23
24
  platformRoleOverride: PlatformRoleOverride | null;
24
25
  };
@@ -38,6 +39,14 @@ export declare function hasActiveServers(): boolean;
38
39
  * Get current mode
39
40
  */
40
41
  export declare function getCurrentMode(): PlaycademyMode;
42
+ /**
43
+ * Get the parent-game context for child mode (null unless configured)
44
+ */
45
+ export declare function getParentOptions(): PlaycademyParentOptions | null;
46
+ /**
47
+ * Set the parent-game context for child mode
48
+ */
49
+ export declare function setParentOptions(parent: PlaycademyParentOptions | null): void;
41
50
  /**
42
51
  * Set current mode
43
52
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Type exports for the Playcademy Vite Plugin
3
3
  */
4
- export type { PlaycademyExportOptions, PlaycademySandboxOptions, PlaycademyDisplayOptions, PlaycademyPluginOptions, PlaycademyTimebackOptions, PlaycademyMode, } from './options';
5
- export type { BannerOptions, GameBackendDevServerOptions, GameBackendServerManager, HotkeyOptions, PlatformModeOptions, PlatformRoleOverride, PlaycademyOutputData, PluginContext, ProjectInfo, ResolvedPluginOptions, SandboxManager, StandaloneModeOptions, TimebackCourseConfig, TimebackPluginContext, TimebackRoleOverride, } from './internal';
4
+ export type { PlaycademyExportOptions, PlaycademySandboxOptions, PlaycademyDisplayOptions, PlaycademyPluginOptions, PlaycademyTimebackOptions, PlaycademyMode, PlaycademyParentOptions, ShellCorner, } from './options';
5
+ export type { BannerOptions, GameBackendDevServerOptions, GameBackendServerManager, HotkeyOptions, PlatformModeOptions, PlatformRoleOverride, PlaycademyOutputData, PluginContext, ProjectInfo, ResolvedDisplayOptions, ResolvedPluginOptions, SandboxManager, StandaloneModeOptions, TimebackCourseConfig, TimebackPluginContext, TimebackRoleOverride, } from './internal';
6
6
  export { TIMEBACK_ROLES, PLATFORM_ROLES } from './internal';
@@ -2,7 +2,16 @@
2
2
  * Internal plugin state and context types
3
3
  */
4
4
  import type { ResolvedConfig } from 'vite';
5
- import type { PlaycademyMode, PlaycademyTimebackOptions } from './options';
5
+ import type { PlaycademyMode, PlaycademyParentOptions, PlaycademyTimebackOptions, ShellCorner } from './options';
6
+ /**
7
+ * Display options with every default applied.
8
+ */
9
+ export interface ResolvedDisplayOptions {
10
+ hideBadge: boolean;
11
+ badgePosition: ShellCorner;
12
+ hideRelayPanel: boolean;
13
+ relayPanelPosition: ShellCorner;
14
+ }
6
15
  /**
7
16
  * TimeBack roles that can be cycled through in dev mode
8
17
  */
@@ -19,6 +28,7 @@ export type PlatformRoleOverride = (typeof PLATFORM_ROLES)[number];
19
28
  export interface ResolvedPluginOptions {
20
29
  configPath?: string;
21
30
  mode: PlaycademyMode;
31
+ parent?: PlaycademyParentOptions;
22
32
  gameBackendPort: number;
23
33
  sandboxPort: number;
24
34
  dashboardAppPort: number;
@@ -32,7 +42,7 @@ export interface ResolvedPluginOptions {
32
42
  seed: boolean;
33
43
  memoryOnly: boolean;
34
44
  databasePath?: string;
35
- hideBadge: boolean;
45
+ display: ResolvedDisplayOptions;
36
46
  timeback?: PlaycademyTimebackOptions | false;
37
47
  }
38
48
  /**
@@ -134,7 +144,7 @@ export interface PlatformModeOptions {
134
144
  seed: boolean;
135
145
  memoryOnly: boolean;
136
146
  databasePath?: string;
137
- hideBadge: boolean;
147
+ display: ResolvedDisplayOptions;
138
148
  gameBackendPort: number;
139
149
  configPath?: string;
140
150
  timeback?: PlaycademyTimebackOptions | false;
@@ -1,17 +1,51 @@
1
1
  /**
2
2
  * Plugin configuration options
3
3
  */
4
+ import type { ParentGameContext, PlaycademyMode as SdkPlaycademyMode } from '@playcademy/sdk/types';
4
5
  /**
5
6
  * Plugin operation mode
6
7
  *
7
8
  * Controls how the Vite plugin operates during development:
8
9
  * - `'platform'`: Full Playcademy platform experience with sandbox server, backend bundling, and shell wrapper (default)
9
10
  * - `'demo'`: Shell-backed development mode that initializes the SDK with `mode: 'demo'`
11
+ * - `'child'`: Shell impersonates a parent game: INIT carries `mode: 'child'` and the
12
+ * `parent` block, and the shell displays what the game relays (requires the `parent` option)
10
13
  * - `'standalone'`: Backend only, no sandbox or shell
11
14
  *
15
+ * The union is the SDK's own `PlaycademyMode`, aliased so the two can
16
+ * never drift: whatever mode the plugin serves lands verbatim in the
17
+ * game's INIT payload.
18
+ *
12
19
  * @default 'platform'
13
20
  */
14
- export type PlaycademyMode = 'platform' | 'demo' | 'standalone';
21
+ export type PlaycademyMode = SdkPlaycademyMode;
22
+ /**
23
+ * Parent-game context the dev shell presents in `mode: 'child'`.
24
+ *
25
+ * In production a child game is launched by a parent game via
26
+ * `client.embed.launch()`; the dev shell plays that parent role locally so a
27
+ * child game can be developed without one. See the parent-child game
28
+ * embedding proposal (`docs/dev/timeback/` in the platform repo).
29
+ *
30
+ * Fields mirror the SDK's `ParentGameContext`, and each is typed from it
31
+ * directly. The block the dev shell actually sends is additionally checked
32
+ * against `ParentGameContext` where it is built (see the server middleware),
33
+ * so it cannot drift from what a real parent game would send.
34
+ */
35
+ export interface PlaycademyParentOptions {
36
+ /**
37
+ * Parent game id stamped into the INIT payload's `parent` block.
38
+ *
39
+ * @default 'dev-parent-game'
40
+ */
41
+ gameId?: ParentGameContext['gameId'];
42
+ /**
43
+ * The `LaunchIntent` delivered to the game as `client.parent.intent`:
44
+ * which lesson to serve (`lessonId`, in the game's own vocabulary),
45
+ * and at which pedagogy stage (`eLevel`, the platform's E1-E4 taxonomy).
46
+ */
47
+ intent: ParentGameContext['intent'];
48
+ }
15
49
  /**
16
50
  * Configuration for developing a game's dashboard app.
17
51
  *
@@ -276,25 +310,43 @@ export interface PlaycademyTimebackOptions {
276
310
  */
277
311
  courses?: Record<string, 'mock' | string | null | false>;
278
312
  }
313
+ /**
314
+ * Screen corner for the dev shell's overlay elements.
315
+ */
316
+ export type ShellCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
279
317
  /**
280
318
  * Configuration options for the development shell wrapper
281
319
  *
282
320
  * The shell provides the platform UI during development, including the
283
- * Playcademy badge and iframe wrapper used by `platform` and `demo` modes.
321
+ * Playcademy badge and iframe wrapper used by shell modes (`platform`,
322
+ * `demo`, and `child`).
284
323
  */
285
324
  export interface PlaycademyDisplayOptions {
286
325
  /**
287
- * Hide the Playcademy badge in the corner during development.
326
+ * Hide the Playcademy badge during development.
288
327
  *
289
328
  * @default false
290
- * @example
291
- * ```ts
292
- * display: {
293
- * hideBadge: true // Hide the badge
294
- * }
295
- * ```
296
329
  */
297
330
  hideBadge?: boolean;
331
+ /**
332
+ * Corner the badge sits in.
333
+ *
334
+ * @default 'top-left'
335
+ */
336
+ badgePosition?: ShellCorner;
337
+ /**
338
+ * Hide the child-mode relay panel (the live view of what the game
339
+ * relays to its parent: timing and the end-activity report).
340
+ *
341
+ * @default false
342
+ */
343
+ hideRelayPanel?: boolean;
344
+ /**
345
+ * Corner the relay panel sits in.
346
+ *
347
+ * @default 'bottom-left'
348
+ */
349
+ relayPanelPosition?: ShellCorner;
298
350
  }
299
351
  /**
300
352
  * Main configuration options for the Playcademy Vite plugin
@@ -345,6 +397,7 @@ export interface PlaycademyPluginOptions {
345
397
  *
346
398
  * - `'platform'`: Full development experience with sandbox server and shell (recommended)
347
399
  * - `'demo'`: Sandbox + shell, but initializes the SDK with `mode: 'demo'`
400
+ * - `'child'`: Sandbox + shell, but the shell plays a parent game (requires `parent`)
348
401
  * - `'standalone'`: Backend bundling only, no platform features
349
402
  *
350
403
  * Most games should use `'platform'` mode; use `'demo'` to exercise
@@ -359,6 +412,20 @@ export interface PlaycademyPluginOptions {
359
412
  * ```
360
413
  */
361
414
  mode?: PlaycademyMode;
415
+ /**
416
+ * Parent-game context for `mode: 'child'`: what the dev shell's INIT
417
+ * carries in its `parent` block. Required when `mode` is `'child'`;
418
+ * also enables `'child'` in the `m` hotkey's mode cycle.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * {
423
+ * mode: 'child',
424
+ * parent: { intent: { lessonId: 'two-digit-add', eLevel: 'E2' } }
425
+ * }
426
+ * ```
427
+ */
428
+ parent?: PlaycademyParentOptions;
362
429
  /**
363
430
  * Develop a dashboard app instead of a game.
364
431
  *
@@ -458,7 +525,8 @@ export interface PlaycademyPluginOptions {
458
525
  * ```ts
459
526
  * {
460
527
  * display: {
461
- * hideBadge: true
528
+ * hideBadge: true,
529
+ * relayPanelPosition: 'bottom-right'
462
530
  * }
463
531
  * }
464
532
  * ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "1.2.1-beta.3",
3
+ "version": "1.2.1-beta.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,21 +19,21 @@
19
19
  "dependencies": {
20
20
  "archiver": "^7.0.1",
21
21
  "picocolors": "^1.1.1",
22
- "playcademy": "0.28.1-beta.3"
22
+ "playcademy": "0.28.1-beta.5"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@electric-sql/pglite": "^0.3.16",
26
26
  "@inquirer/prompts": "^7.8.6",
27
27
  "@playcademy/constants": "0.0.1",
28
- "@playcademy/sandbox": "0.7.1-beta.3",
29
- "@playcademy/sdk": "0.16.1-beta.3",
28
+ "@playcademy/sandbox": "0.7.1-beta.5",
29
+ "@playcademy/sdk": "0.16.1-beta.5",
30
30
  "@playcademy/types": "0.0.1",
31
31
  "@playcademy/utils": "0.0.1",
32
32
  "@types/archiver": "^6.0.3",
33
33
  "@types/bun": "1.3.5"
34
34
  },
35
35
  "peerDependencies": {
36
- "@playcademy/sdk": ">=0.12.0",
36
+ "@playcademy/sdk": ">=0.17.0",
37
37
  "typescript": "^5 || ^6",
38
38
  "vite": "^5 || ^6 || ^7 || ^8"
39
39
  }