@playcademy/vite-plugin 1.2.1-beta.4 → 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.4",
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.4",
25915
+ version: "0.7.1-beta.5",
25891
25916
  description: "Local development server for Playcademy game development",
25892
25917
  type: "module",
25893
25918
  exports: {
@@ -181514,10 +181539,24 @@ var shell_default2 = `<!doctype html>
181514
181539
  height: 100vh;
181515
181540
  overflow: hidden;
181516
181541
  }
181517
- #badge {
181518
- position: fixed;
181542
+ .corner-top-left {
181519
181543
  top: 0.5rem;
181520
181544
  left: 0.5rem;
181545
+ }
181546
+ .corner-top-right {
181547
+ top: 0.5rem;
181548
+ right: 0.5rem;
181549
+ }
181550
+ .corner-bottom-left {
181551
+ bottom: 0.5rem;
181552
+ left: 0.5rem;
181553
+ }
181554
+ .corner-bottom-right {
181555
+ bottom: 0.5rem;
181556
+ right: 0.5rem;
181557
+ }
181558
+ #badge {
181559
+ position: fixed;
181521
181560
  background: rgba(23, 23, 23, 0.9);
181522
181561
  color: white;
181523
181562
  padding: 0.25rem 0.5rem;
@@ -181532,6 +181571,28 @@ var shell_default2 = `<!doctype html>
181532
181571
  #badge.offline {
181533
181572
  background: rgba(211, 47, 47, 0.9);
181534
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
+ }
181535
181596
  #frame {
181536
181597
  position: absolute;
181537
181598
  inset: 0;
@@ -181545,11 +181606,26 @@ var shell_default2 = `<!doctype html>
181545
181606
  </style>
181546
181607
  </head>
181547
181608
  <body>
181548
- <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>
181549
181615
  <iframe id="frame" class="hidden"></iframe>
181550
181616
 
181551
181617
  <script type="module">
181552
- 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
+ // ============================================================
181553
181629
 
181554
181630
  const CONFIG = {
181555
181631
  sandboxUrl: '{{SANDBOX_URL}}',
@@ -181557,27 +181633,149 @@ var shell_default2 = `<!doctype html>
181557
181633
  gameToken: '{{GAME_TOKEN}}',
181558
181634
  gameUrl: '{{GAME_URL}}' || undefined,
181559
181635
  mode: '{{MODE}}',
181560
- timebackJson: '{{TIMEBACK_DATA}}',
181636
+ timebackJson: {{TIMEBACK_DATA}},
181637
+ parentJson: {{PARENT_DATA}},
181561
181638
  hideBadge: '{{HIDE_BADGE}}' === 'true',
181639
+ hideRelayPanel: '{{HIDE_RELAY_PANEL}}' === 'true',
181562
181640
  }
181563
181641
 
181564
- const timeback = (() => {
181642
+ function parseJsonOrNull(raw) {
181565
181643
  try {
181566
- return JSON.parse(CONFIG.timebackJson)
181644
+ return JSON.parse(raw)
181567
181645
  } catch {
181568
181646
  return null
181569
181647
  }
181570
- })()
181648
+ }
181571
181649
 
181572
- const badge = document.getElementById('badge')
181573
- const frame = document.getElementById('frame')
181650
+ const timeback = parseJsonOrNull(CONFIG.timebackJson)
181651
+ const parentContext = parseJsonOrNull(CONFIG.parentJson)
181574
181652
 
181575
- if (CONFIG.hideBadge) {
181576
- 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
+ )
181577
181657
  }
181578
181658
 
181659
+ // ============================================================
181660
+ // DOM + logging
181661
+ // ============================================================
181662
+
181663
+ const badge = document.getElementById('badge')
181664
+ const frame = document.getElementById('frame')
181665
+
181579
181666
  const log = (...args) => window.PLAYCADEMY_DEBUG && console.log('[DevShell]', ...args)
181580
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
+
181581
181779
  async function checkSandbox() {
181582
181780
  // Use the public /health endpoint as a pure liveness probe.
181583
181781
  // We used to hit /api/users/me here, but that's \`requireNonAnonymous\`
@@ -181598,14 +181796,25 @@ var shell_default2 = `<!doctype html>
181598
181796
  }
181599
181797
  }
181600
181798
 
181601
- function initHandshake(timebackData) {
181799
+ // ============================================================
181800
+ // INIT handshake (shell → game)
181801
+ // ============================================================
181802
+
181803
+ function initHandshake() {
181602
181804
  const payload = {
181603
181805
  baseUrl: CONFIG.sandboxUrl,
181604
181806
  gameUrl: CONFIG.gameUrl,
181605
181807
  gameId: CONFIG.gameId,
181606
181808
  token: CONFIG.gameToken,
181607
181809
  mode: CONFIG.mode,
181608
- 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,
181609
181818
  }
181610
181819
 
181611
181820
  let interval, timeout
@@ -181628,11 +181837,14 @@ var shell_default2 = `<!doctype html>
181628
181837
  frame.classList.remove('hidden')
181629
181838
  log('Frame loaded, starting handshake')
181630
181839
  send()
181631
- 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.
181632
181844
  timeout = setTimeout(() => {
181633
181845
  stop()
181634
181846
  log('Handshake timeout')
181635
- }, 10000)
181847
+ }, HANDSHAKE_MAX_DURATION_MS)
181636
181848
  }
181637
181849
 
181638
181850
  messaging.listen(MessageEvents.READY, () => {
@@ -181643,29 +181855,67 @@ var shell_default2 = `<!doctype html>
181643
181855
  frame.src = '/'
181644
181856
  }
181645
181857
 
181646
- window.addEventListener('message', e => {
181647
- if (e.source !== frame.contentWindow) return
181648
- const { type, ...payload } = e.data || {}
181649
- if (type?.startsWith('PLAYCADEMY_')) {
181650
- log('Message:', type, payload)
181651
- 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)
181652
181876
  }
181877
+
181878
+ messaging.send(type, payload)
181653
181879
  })
181654
181880
 
181655
- 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())
181656
181898
  </script>
181657
181899
  </body>
181658
181900
  </html>
181659
181901
  `;
181660
181902
 
181661
181903
  // src/server/middleware.ts
181904
+ function jsonScriptLiteral(json6) {
181905
+ return JSON.stringify(json6).replace(/</g, String.raw`\u003c`);
181906
+ }
181662
181907
  function generateLoaderHTML(sandboxUrl, gameSlug, gameId, options, gameUrl) {
181663
181908
  const timebackJson = generateTimebackJson(options.timeback);
181664
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";
181665
181915
  const tokenIdentity = mode === "demo" ? "anonymous" : getPlatformRoleOverride() ?? "player";
181666
181916
  const gameToken = createSandboxGameToken(gameSlug, tokenIdentity);
181667
181917
  const handshakeGameId = gameId || gameSlug;
181668
- 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);
181669
181919
  }
181670
181920
  function devServerMiddleware(server, sandbox, gameUrl, options) {
181671
181921
  server.middlewares.use("/", (req, res, next) => {
@@ -181724,7 +181974,7 @@ async function recreateSandbox(options) {
181724
181974
  if (sandbox.project && serverState.gameBackend) {
181725
181975
  const gameUrl = `http://localhost:${serverState.gameBackend.port}`;
181726
181976
  devServerMiddleware(viteServer, sandbox, gameUrl, {
181727
- hideBadge: platformModeOptions.hideBadge,
181977
+ display: platformModeOptions.display,
181728
181978
  timeback: timebackDisabled ? undefined : {
181729
181979
  baseCourses: sandbox.project.timebackCourses ?? [],
181730
181980
  overrides: timebackOptions
@@ -182029,7 +182279,7 @@ async function configurePlatformMode(server, viteConfig, options) {
182029
182279
  overrides: timebackOptions
182030
182280
  };
182031
182281
  devServerMiddleware(server, sandbox, gameUrl, {
182032
- hideBadge: options.hideBadge,
182282
+ display: options.display,
182033
182283
  timeback: timebackContext
182034
182284
  });
182035
182285
  }
@@ -182081,13 +182331,21 @@ async function configureStandaloneMode(server, viteConfig, options) {
182081
182331
  // src/server/hotkeys/toggle-mode.ts
182082
182332
  async function toggleMode(options) {
182083
182333
  const currentMode = getCurrentMode();
182084
- const newMode = getNextMode(currentMode);
182334
+ let newMode = getNextMode(currentMode, getParentOptions() !== null);
182085
182335
  const viteServer = getViteServerRef();
182086
182336
  const prefix2 = createLogPrefix("playcademy");
182087
182337
  if (!viteServer) {
182088
182338
  options.viteConfig.logger.error(`${prefix2} ${import_picocolors16.red("Cannot toggle mode: no Vite server reference")}`);
182089
182339
  return;
182090
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
+ }
182091
182349
  await cleanupServers();
182092
182350
  await new Promise((resolve2) => setTimeout(resolve2, 100));
182093
182351
  setCurrentMode(newMode);
@@ -182104,7 +182362,7 @@ async function toggleMode(options) {
182104
182362
  function toggleModeHotkey(options) {
182105
182363
  return {
182106
182364
  key: "m",
182107
- 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`,
182108
182366
  action: () => toggleMode(options)
182109
182367
  };
182110
182368
  }
@@ -182145,8 +182403,12 @@ async function configureServerHook(server, context) {
182145
182403
  if (!context.viteConfig) {
182146
182404
  throw new Error("[Playcademy] Vite config not resolved before configureServer");
182147
182405
  }
182406
+ if (context.options.mode === "child") {
182407
+ assertChildParentOptions(context.options.parent);
182408
+ }
182148
182409
  setViteServerRef(server);
182149
182410
  setCurrentMode(context.options.mode);
182411
+ setParentOptions(context.options.parent ?? null);
182150
182412
  setupProcessShutdownHandlers();
182151
182413
  await registerAssetMiddleware(server, context.viteConfig.root, context.options.configPath);
182152
182414
  if (hasActiveServers()) {
@@ -182164,7 +182426,7 @@ async function configureServerHook(server, context) {
182164
182426
  seed: context.options.seed,
182165
182427
  memoryOnly: context.options.memoryOnly,
182166
182428
  databasePath: context.options.databasePath,
182167
- hideBadge: context.options.hideBadge,
182429
+ display: context.options.display,
182168
182430
  gameBackendPort: context.options.gameBackendPort,
182169
182431
  configPath: context.options.configPath,
182170
182432
  timeback: context.options.timeback
@@ -182193,7 +182455,14 @@ function resolveShellSdkId(id) {
182193
182455
  }
182194
182456
  function loadShellSdk(id) {
182195
182457
  if (id === SHELL_SDK_ID) {
182196
- 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
+ `);
182197
182466
  }
182198
182467
  }
182199
182468
 
@@ -182266,6 +182535,7 @@ function resolveOptions(options) {
182266
182535
  return {
182267
182536
  configPath: options.configPath,
182268
182537
  mode: options.mode ?? "platform",
182538
+ parent: options.parent,
182269
182539
  gameBackendPort: options.gameBackendPort ?? DEFAULT_PORTS4.GAME,
182270
182540
  sandboxPort,
182271
182541
  dashboardAppPort: options.dashboardAppPort ?? DEFAULT_PORTS4.DASHBOARD_APP,
@@ -182279,7 +182549,12 @@ function resolveOptions(options) {
182279
182549
  seed: sandboxOptions.seed ?? true,
182280
182550
  memoryOnly: sandboxOptions.memoryOnly ?? false,
182281
182551
  databasePath: sandboxOptions.databasePath,
182282
- 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
+ },
182283
182558
  timeback: options.timeback
182284
182559
  };
182285
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.4",
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.4"
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.4",
29
- "@playcademy/sdk": "0.16.1-beta.4",
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
  }