@homie-rocks/studio 0.32.0 → 0.33.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
@@ -24,8 +24,9 @@
24
24
  * "built, not started on this computer". A release that could not be signed is said as UNSIGNED and is not `ok`.
25
25
  */
26
26
  import { spawn } from 'node:child_process';
27
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
- import { homedir } from 'node:os';
27
+ import { X509Certificate } from 'node:crypto';
28
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
29
+ import { homedir, tmpdir } from 'node:os';
29
30
  import { dirname, join, relative } from 'node:path';
30
31
  import { build as buildSite, uiOf } from './build.mjs';
31
32
  import { compareVersions } from './changelog.mjs';
@@ -222,6 +223,20 @@ export async function standaloneRows({ platform = process.platform, exec = runTo
222
223
  fix: mac && team ? null : { who: 'person', open: 'https://developer.apple.com/account#MembershipDetailsCard', say: `With an Apple Developer membership: sign in to it in Xcode (Settings, Accounts), and put your team id (ten letters and digits, on that page) in HOMIE_APPLE_TEAM. ${ENV_HOW}` },
223
224
  });
224
225
  }
226
+ // A phone for trying a build on (standalone run --for ios --device): what Xcode's own device list says is
227
+ // connected now, by model, and whether its Developer Mode is on. Nothing is asked of the phone itself.
228
+ if (mac) {
229
+ const found = await phones({ exec, env });
230
+ const here = found.ok ? found.phones.filter((p) => p.here) : [];
231
+ const ready = here.filter((p) => p.trusted && p.developerMode === true);
232
+ const one = (p) => `${p.model}${!p.trusted ? ', which does not trust this Mac yet' : p.developerMode === true ? ', Developer Mode on' : p.developerMode === false ? ', Developer Mode OFF' : ', Developer Mode not known'}`;
233
+ rows.push({
234
+ id: 'standalone-phone', label: 'A phone for trying a build', need: 'optional', state: !found.ok ? 'unknown' : ready.length === 1 && here.length === 1 ? 'ok' : 'optional',
235
+ detail: !found.ok ? `could not check (${found.why})` : here.length ? `connected: ${here.map(one).join('; ')}${here.length > 1 ? ' (a run takes one phone: unplug the others)' : ''}` : 'no iPhone or iPad is connected now',
236
+ unlocks: 'the game on your own iPhone or iPad, over the cable (standalone run <game> --for ios --device): it adds the phone to your Apple team\'s device list',
237
+ fix: !found.ok || (ready.length === 1 && here.length === 1) ? null : !here.length ? { who: 'person', say: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer.' } : here.some((p) => !p.trusted) ? { who: 'person', say: deviceTrouble('not paired').fix } : here.some((p) => p.developerMode !== true) ? { who: 'person', say: deviceTrouble('Developer Mode disabled').fix } : { who: 'person', say: 'Unplug all but the one phone to try the game on.' },
238
+ });
239
+ }
225
240
  // steamcmd: only to upload, which a person does.
226
241
  {
227
242
  const r = await exec(platform === 'win32' ? 'where' : 'which', ['steamcmd'], { timeout: 5000 });
@@ -292,7 +307,7 @@ export function toolkitNote(root) {
292
307
  try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
293
308
  const pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']);
294
309
  if (!pinned || compareVersions(pinned, STANDALONE_SINCE) >= 0) return null;
295
- return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site cannot answer a standalone copy, so every copy plays OFFLINE until the studio is upgraded to ${STANDALONE_SINCE} or later AND deployed again.`;
310
+ return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site does not let a standalone copy read which room to join, so in a copy Quick play is OFFLINE until the studio is upgraded to ${STANDALONE_SINCE} or later AND deployed again. A room made or joined by its code still connects (seen against 0.31.0, not promised for every older version).`;
296
311
  }
297
312
 
298
313
  /** Everything a plan and a build both need to know, read once. */
@@ -366,7 +381,7 @@ export async function standalonePlan(root, id, opts = {}) {
366
381
  icon: { from: s.icon.from, note: s.icon.note }, steam: s.meta.steam,
367
382
  dir: relative(root, s.dir), targets, rows: s.rows, warnings: s.warnings, missing: MISSING, pins: STANDALONE_PINS,
368
383
  ...(s.note ? { toolkitNote: s.note } : {}),
369
- needs: `The live site must run @homie-rocks/studio ${STANDALONE_SINCE} or later for a copy to find rooms (upgrade the studio, then deploy). This plan did not ask the site which version it runs.`,
384
+ needs: `Quick play finds a room only when the live site runs @homie-rocks/studio ${STANDALONE_SINCE} or later (upgrade the studio, then deploy). A room made or joined by its code works with an older site too (seen against 0.31.0, not promised for every older version). This plan did not ask the site which version it runs.`,
370
385
  ...(refused ? { why: refused.why, instead: refused.instead, needsField: 'appId' } : {}),
371
386
  };
372
387
  }
@@ -560,15 +575,22 @@ async function capProject(s, platform, { say }) {
560
575
  const patch = (file, fn) => { if (!existsSync(file)) return false; const before = readFileSync(file, 'utf8'); const after = fn(before); if (after !== before) writeFileSync(file, after); return true; };
561
576
  const freshOut = (s, target) => { const out = join(s.dir, 'out', target, s.kind); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true }); return out; };
562
577
 
563
- async function buildIos(s, { say }) {
578
+ /** The iOS project with this build's web folder in it, ready for xcodebuild: { ok, notes, project } or { ok: false, why }. */
579
+ async function iosProject(s, { say }) {
564
580
  const p = await capProject(s, 'ios', { say });
565
- if (!p.ok) return { state: 'failed', why: p.why };
581
+ if (!p.ok) return { ok: false, why: p.why };
566
582
  const notes = [...p.notes];
567
583
  if (!patch(join(s.dir, 'ios', 'App', 'App', 'Info.plist'), (t) => patchInfoPlist(t, s.meta.orientation))) notes.push('Info.plist was not where Capacitor puts it: the way the game is held was not set');
568
584
  const sync = await tool(s, 'cap', ['sync', 'ios'], { env: p.env, timeout: 15 * 60_000 });
569
- if (sync.code !== 0) return { state: 'failed', why: `cap sync ios failed: ${tail(sync)}` };
585
+ if (sync.code !== 0) return { ok: false, why: `cap sync ios failed: ${tail(sync)}` };
570
586
  const app = join(s.dir, 'ios', 'App');
571
- const project = existsSync(join(app, 'App.xcworkspace')) ? ['-workspace', join(app, 'App.xcworkspace')] : ['-project', join(app, 'App.xcodeproj')];
587
+ return { ok: true, notes, project: existsSync(join(app, 'App.xcworkspace')) ? ['-workspace', join(app, 'App.xcworkspace')] : ['-project', join(app, 'App.xcodeproj')] };
588
+ }
589
+
590
+ async function buildIos(s, { say }) {
591
+ const p = await iosProject(s, { say });
592
+ if (!p.ok) return { state: 'failed', why: p.why };
593
+ const { notes, project } = p;
572
594
  const derived = join(s.dir, 'ios', 'build');
573
595
  const version = [`MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`];
574
596
  const env = toolEnv(s.env);
@@ -753,9 +775,18 @@ export async function standaloneBuild(root, id, opts = {}) {
753
775
  /* ------------------------------------------------------------------ run, steam, ci, deploy */
754
776
 
755
777
  /** `standalone run <game> [--for mac|windows|linux|ios|android]`: the copy that was built, started here. */
756
- export async function standaloneRun(root, id, { for: said = null, platform = process.platform, env = process.env, exec = runTool, home = homedir() } = {}) {
778
+ export async function standaloneRun(root, id, opts = {}) {
779
+ const { for: said = null, platform = process.platform, env = process.env, exec = runTool, home = homedir(), device = false } = opts;
757
780
  const g = gameOf(root, id);
758
781
  if (g.why) return { ok: false, command: 'standalone run', why: g.why };
782
+ // --device: onto the one phone that is plugged in (runOnPhone). It is the person's yes to what that changes.
783
+ if (device) {
784
+ const asked = targetsOf(said ?? 'ios');
785
+ if (asked.why || asked.targets.length !== 1) return { ok: false, command: 'standalone run', why: asked.why ?? '--device takes one target: --for ios' };
786
+ if (asked.targets[0] === 'android') return { ok: false, command: 'standalone run', why: '--device is for an iPhone or iPad in this version. For an Android phone, leave --device out: `standalone run <game> --for android` hands over to Capacitor, which lists the phones and emulators it finds over adb and asks which one. That has been run on an emulator, never on a real Android phone.' };
787
+ if (asked.targets[0] !== 'ios') return { ok: false, command: 'standalone run', why: `--device puts the game on a phone: --for ios. A ${asked.targets[0]} build runs on a computer (leave --device out).` };
788
+ return runOnPhone(root, id, opts);
789
+ }
759
790
  const dir = standaloneDir(root, g.game.id);
760
791
  const host = { darwin: 'mac', win32: 'windows', linux: 'linux' }[platform] ?? 'linux';
761
792
  const t = targetsOf(said ?? host);
@@ -865,4 +896,179 @@ export function standaloneDeployNotes(root) {
865
896
  return notes;
866
897
  }
867
898
 
899
+ /* ------------------------------------------------------------------ a real iPhone or iPad */
900
+
901
+ /** What `--device` changes outside this computer, said before it happens, in the result, in the tool and in the skill. */
902
+ export const DEVICE_ADDS = 'This adds the phone to your Apple team\'s list of development devices (a team may register a limited number a year, and a device stays on the list until the membership year renews) and lets Xcode make a development profile for this app. Apple signs the app for that phone only: it is a build to try, never one for the store.';
903
+
904
+ /** A devicectl command whose answer is a JSON file: { r, json } (json null when it wrote none). */
905
+ async function jsonOf(s, args, { timeout = 60_000 } = {}) {
906
+ const dir = mkdtempSync(join(tmpdir(), 'standalone-device-'));
907
+ const file = join(dir, 'out.json');
908
+ const r = await s.exec('xcrun', ['devicectl', ...args, '--json-output', file], { env: toolEnv(s.env), timeout });
909
+ let json = null;
910
+ try { json = JSON.parse(readFileSync(file, 'utf8')); } catch { json = null; }
911
+ rmSync(dir, { recursive: true, force: true });
912
+ return { r, json };
913
+ }
914
+
915
+ /**
916
+ * The iPhones and iPads this Mac knows, as Xcode's own devicectl lists them: { ok, phones: [{ id, udid, model,
917
+ * trusted, here, developerMode }] } or { ok: false, why }. `here`: plugged in or on the network now. An id never
918
+ * leaves this module in a result or a line: a person is told a phone by its model.
919
+ */
920
+ export async function phones({ exec = runTool, env = process.env } = {}) {
921
+ const { r, json } = await jsonOf({ exec, env }, ['list', 'devices']);
922
+ const list = json?.result?.devices;
923
+ if (r.code !== 0 || !Array.isArray(list)) return { ok: false, why: r.code === 127 ? 'xcrun is not here (Xcode is not installed)' : `Xcode's device list did not answer (${tail(r, 2) || `code ${r.code}`})` };
924
+ return {
925
+ ok: true,
926
+ phones: list.filter((d) => d?.hardwareProperties?.reality === 'physical' && ['iPhone', 'iPad'].includes(d?.hardwareProperties?.deviceType)).map((d) => ({
927
+ id: String(d.identifier ?? ''), udid: String(d.hardwareProperties.udid ?? ''), model: String(d.hardwareProperties.marketingName ?? d.hardwareProperties.deviceType),
928
+ trusted: d.connectionProperties?.pairingState === 'paired', here: ['connected', 'disconnected'].includes(d.connectionProperties?.tunnelState),
929
+ developerMode: d.deviceProperties?.developerModeStatus === 'enabled' ? true : d.deviceProperties?.developerModeStatus === 'disabled' ? false : null,
930
+ })),
931
+ };
932
+ }
933
+
934
+ const TEAM_ID = /^[A-Z0-9]{10}$/;
935
+ /**
936
+ * The Apple team a build to try is signed for: HOMIE_APPLE_TEAM, else the ONE team among the keychain's development
937
+ * identities. An identity's name carries a person's id, not the team's: the team is in its certificate (the OU of
938
+ * its subject), which is read here and nothing else of it is kept. { team, from } or { team: null, why, teams }.
939
+ */
940
+ export async function appleTeam({ env = process.env, exec = runTool } = {}) {
941
+ const said = String(env.HOMIE_APPLE_TEAM ?? '').trim();
942
+ if (said) return TEAM_ID.test(said) ? { team: said, from: 'HOMIE_APPLE_TEAM' } : { team: null, teams: 0, why: 'HOMIE_APPLE_TEAM is set and is not a team id (ten capital letters and digits, on your Apple Developer account\'s Membership page)' };
943
+ const ids = await exec('security', ['find-identity', '-v', '-p', 'codesigning'], { timeout: 10_000 });
944
+ if (ids.code !== 0) return { team: null, teams: 0, why: 'could not check the keychain for a development identity (the security tool did not answer)' };
945
+ const hashes = new Set(ids.stdout.split('\n').filter((l) => /"(Apple Development|iPhone Developer): /.test(l)).map((l) => /\b([0-9A-F]{40})\b/.exec(l)?.[1]).filter(Boolean));
946
+ if (!hashes.size) return { team: null, teams: 0, why: 'the keychain has no Apple Development identity: sign in to your Apple account in Xcode (Settings, Accounts), which makes one' };
947
+ const certs = await exec('security', ['find-certificate', '-a', '-p'], { timeout: 20_000 });
948
+ const teams = new Set();
949
+ for (const pem of certs.stdout.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []) {
950
+ try {
951
+ const c = new X509Certificate(pem);
952
+ if (!hashes.has(c.fingerprint.replace(/:/g, ''))) continue;
953
+ const ou = /^OU=([A-Z0-9]{10})$/m.exec(c.subject)?.[1];
954
+ if (ou) teams.add(ou);
955
+ } catch { /* not a certificate this reads */ }
956
+ }
957
+ if (teams.size === 1) return { team: [...teams][0], from: 'the one Apple team among the keychain\'s development identities' };
958
+ return { team: null, teams: teams.size, why: teams.size ? `the keychain has development identities of ${teams.size} Apple teams, and which one is yours to say` : 'the team of the keychain\'s development identity could not be read' };
959
+ }
960
+
961
+ /**
962
+ * What stopped xcodebuild, devicectl or the phone, in the words a person can act on: { stage, why, fix } or null
963
+ * when the text is none of the things met so far (the caller then says the tool's own last lines).
964
+ */
965
+ export function deviceTrouble(text) {
966
+ const t = String(text ?? '');
967
+ if (/Developer Mode disabled|enable Developer Mode|developer mode is (?:not enabled|disabled)/i.test(t)) return { stage: 'developer-mode', why: 'Developer Mode is off on the phone, so it takes no build to try', fix: 'On the phone: Settings, Privacy & Security, Developer Mode, turn it on. The phone restarts, then asks once more to turn it on: say yes, unlock it, and run this again. (The switch appears only after the phone has been plugged in to a Mac with Xcode.)' };
968
+ if (/No Accounts?:|No Account for Team|Add a new account in Accounts settings|not signed in|requires? (?:a|an) (?:development team|Apple ID)/i.test(t)) return { stage: 'no-account', why: 'Xcode is not signed in to an Apple account of that team, so nothing can sign the app', fix: 'Open Xcode, Settings, Accounts, add your Apple account (a free one can put a game on your own phone for a week; a Developer membership for longer), then run this again.' };
969
+ if (/has no devices from which to generate a provisioning profile|No profiles for '[^']*' were found/i.test(t)) return { stage: 'no-devices', why: 'your Apple team has no registered phone to make a development profile for, and Apple did not register this one', fix: 'Unlock the phone, keep it plugged in and run this again: it registers the phone. If it says the same, open the project once in Xcode (ios/App/App.xcodeproj in the standalone folder), pick the phone and press Run: Xcode registers it and says what stands in the way.' };
970
+ if (/device (?:is|was) (?:passcode )?locked|is locked|Unlock .* to Continue|could not be unlocked|because the device was not, or could not be, unlocked/i.test(t)) return { stage: 'locked', why: 'the phone is locked', fix: 'Unlock the phone, keep it awake and run this again.' };
971
+ if (/not paired|is not trusted|Trust This Computer|pairing/i.test(t)) return { stage: 'not-trusted', why: 'the phone does not trust this Mac yet', fix: 'Unlock the phone, plug it in and tap Trust when it asks about this computer (it asks for the passcode), then run this again.' };
972
+ if (/Timed out waiting for all destinations|Unable to find a (?:destination|device) matching/i.test(t)) return { stage: 'not-ready', why: 'Xcode waited for the phone and it never became ready', fix: 'Unlock the phone, keep it plugged in and awake, and run this again. If Xcode has never seen this phone, open Xcode once with it plugged in (Window, Devices and Simulators) and let it finish preparing the phone.' };
973
+ return null;
974
+ }
975
+
976
+ /**
977
+ * `standalone run <game> --for ios --device`: the game built for the ONE iPhone or iPad that is plugged in, signed
978
+ * for the person's own Apple team, installed over the cable and started, and then LOOKED FOR among the phone's
979
+ * running programs: "started" is said only when it is there. Every way it can stop is an answer with what to do.
980
+ * Nothing of the phone or the team is in the result but the phone's model and where the team's id was found.
981
+ */
982
+ async function runOnPhone(root, id, opts = {}) {
983
+ const say = opts.log ?? (() => {});
984
+ const command = 'standalone run';
985
+ const stop = (stage, why, extra = {}) => ({ ok: false, command, target: 'ios', device: true, stage, why, ...extra });
986
+ if ((opts.platform ?? process.platform) !== 'darwin') return stop('not-a-mac', 'a game goes onto an iPhone from a Mac with Xcode');
987
+ const s = await survey(root, id, { ...opts, for: 'ios', release: false });
988
+ if (s.why) return stop('game', s.why);
989
+ const g = gate('ios', s);
990
+ if (g) return stop('xcode', g.why, { game: s.game.id, row: g.row?.id ?? null, fix: g.row?.fix?.say ?? null });
991
+ const base = { game: s.game.id, name: s.meta.name, appId: s.meta.appId, appIdFrom: s.meta.appIdFrom, site: s.where.url || null, warnings: s.warnings, adds: DEVICE_ADDS };
992
+ // The phone: exactly one, trusted, here, with Developer Mode on.
993
+ const found = await phones(s);
994
+ if (!found.ok) return stop('could-not-check', `could not look for a phone: ${found.why}`, base);
995
+ const here = found.phones.filter((p) => p.here);
996
+ if (!here.length) {
997
+ const known = found.phones.length;
998
+ return stop('no-phone', known ? `no iPhone or iPad is connected now (this Mac knows ${known}: ${found.phones.map((p) => p.model).join(', ')})` : 'no iPhone or iPad is connected to this Mac', { ...base, fix: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer. Then run this again.' });
999
+ }
1000
+ if (here.length > 1) return stop('several', `${here.length} phones are connected (${here.map((p) => p.model).join(', ')}), and this puts the game on one`, { ...base, phones: here.map((p) => ({ model: p.model })), fix: 'Unplug all but the one to try the game on (and turn Wi-Fi off on the others, or they stay connected over the network), then run this again.' });
1001
+ const phone = here[0];
1002
+ const device = { model: phone.model };
1003
+ if (!phone.trusted) return stop('not-trusted', `the ${phone.model} does not trust this Mac yet`, { ...base, phone: device, fix: deviceTrouble('not paired').fix });
1004
+ if (phone.developerMode === false) { const t = deviceTrouble('Developer Mode disabled'); return stop(t.stage, `Developer Mode is off on the ${phone.model}, so it takes no build to try`, { ...base, phone: device, fix: t.fix }); }
1005
+ // The team: said, or the keychain's only one. Where it came from is said; the id is not.
1006
+ const signer = await appleTeam(s);
1007
+ if (!signer.team) return stop('no-team', `no Apple team to sign for: ${signer.why}`, { ...base, phone: device, fix: `Put your team id in HOMIE_APPLE_TEAM (ten capital letters and digits, on your Apple Developer account's Membership page). ${ENV_HOW}` });
1008
+ say(`for the ${phone.model}, signed for ${signer.from === 'HOMIE_APPLE_TEAM' ? 'the team HOMIE_APPLE_TEAM names' : signer.from}`);
1009
+ say(DEVICE_ADDS);
1010
+ const on = { ...base, phone: device, team: { from: signer.from } };
1011
+ // The game, built as the site builds it, in the same project a simulator build uses.
1012
+ if (opts.buildSite !== false) {
1013
+ say(`building ${s.game.id} for the web (the same build the site serves)`);
1014
+ try { await (opts.buildSite ?? buildSite)(root, { only: s.game.id, log: say }); } catch (error) { return stop('build-failed', `the game did not build: ${String(error?.message ?? error)}`, on); }
1015
+ }
1016
+ const tools = await installTools(s, { say });
1017
+ if (!tools.ok) return stop('build-failed', tools.why, on);
1018
+ await writeIcons(s.dir, s.icon, { name: s.meta.name, colours: uiOf(s.game) });
1019
+ try { webBundle(root, s.game, s.dir, { meta: s.meta, target: 'ios', site: s.where.url }); } catch (error) { return stop('build-failed', String(error?.message ?? error).split('\n')[0], on); }
1020
+ const p = await iosProject(s, { say });
1021
+ if (!p.ok) return stop('build-failed', p.why, on);
1022
+ const derived = join(s.dir, 'ios', 'build-device');
1023
+ say('xcodebuild: a Debug build for the phone, signed by Xcode for your team (the first one registers the phone and makes its profile)');
1024
+ const b = await s.exec('xcodebuild', [...p.project, '-scheme', 'App', '-configuration', 'Debug', '-destination', `id=${phone.udid}`, '-derivedDataPath', derived, `MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`, `DEVELOPMENT_TEAM=${signer.team}`, 'CODE_SIGN_STYLE=Automatic', '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', 'build'], { cwd: s.dir, env: toolEnv(s.env), timeout: 40 * 60_000 });
1025
+ const app = join(derived, 'Build', 'Products', 'Debug-iphoneos', 'App.app');
1026
+ // What a tool printed may name the phone or the team: only its meaning, or its last lines with both taken out, is said.
1027
+ const hush = (text) => String(text).split(phone.udid).join('<the phone>').split(phone.id).join('<the phone>').split(signer.team).join('<your team>');
1028
+ if (b.code !== 0 || !existsSync(app)) {
1029
+ const t = deviceTrouble(`${b.stdout}\n${b.stderr}`);
1030
+ return t ? stop(t.stage, t.why, { ...on, fix: t.fix }) : stop('build-failed', `xcodebuild did not finish: ${hush(tail(b, 12))}`, on);
1031
+ }
1032
+ say(`installing it on the ${phone.model}`);
1033
+ const i = await jsonOf(s, ['device', 'install', 'app', '--device', phone.id, app], { timeout: 10 * 60_000 });
1034
+ if (i.r.code !== 0) {
1035
+ const t = deviceTrouble(`${i.r.stdout}\n${i.r.stderr}`);
1036
+ return t ? stop(t.stage, t.why, { ...on, built: true, fix: t.fix }) : stop('install-failed', `the app was built and the phone did not take it: ${hush(tail(i.r, 6))}`, { ...on, built: true });
1037
+ }
1038
+ // Where the phone put it, from the phone's own list of apps: every Capacitor app's program has the same name, so
1039
+ // a running one is told from another game's by where it is installed.
1040
+ const apps = await jsonOf(s, ['device', 'info', 'apps', '--device', phone.id], { timeout: 60_000 });
1041
+ const listed = (apps.json?.result?.apps ?? []).find((x) => x?.bundleIdentifier === s.meta.appId);
1042
+ if (apps.r.code === 0 && Array.isArray(apps.json?.result?.apps) && !listed) return stop('install-failed', 'the phone said it took the app, and the app is not in the phone\'s list of apps', { ...on, built: true });
1043
+ const where = String(listed?.url ?? i.json?.result?.installedApplications?.[0]?.installationURL ?? '');
1044
+ // A locked phone starts nothing. It is asked for now, and waited for a minute, rather than failing at the last step.
1045
+ const wait = opts.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1046
+ const locked = async () => (await jsonOf(s, ['device', 'info', 'lockState', '--device', phone.id], { timeout: 30_000 })).json?.result?.passcodeRequired === true;
1047
+ if (await locked()) {
1048
+ say(`The ${phone.model} is locked: unlock it now (waiting up to a minute).`);
1049
+ for (let t = 0; t < (opts.unlockMs ?? 60_000); t += 3000) { await wait(3000); if (!(await locked())) break; }
1050
+ }
1051
+ say('starting it');
1052
+ const l = await jsonOf(s, ['device', 'process', 'launch', '--device', phone.id, '--terminate-existing', s.meta.appId], { timeout: 2 * 60_000 });
1053
+ if (l.r.code !== 0) {
1054
+ const t = deviceTrouble(`${l.r.stdout}\n${l.r.stderr}`);
1055
+ return stop(t?.stage === 'locked' ? 'locked' : 'not-started', t?.stage === 'locked' ? 'the game is on the phone, and the phone is locked, so it was not started' : `the game is on the phone and did not start: ${hush(tail(l.r, 6))}`, { ...on, built: true, installed: true, fix: `Unlock the phone and tap ${s.meta.name} on its home screen.` });
1056
+ }
1057
+ const pid = Number(l.json?.result?.process?.processIdentifier);
1058
+ // Read back: the game is among the phone's running programs a few seconds later, or it is not said to be running.
1059
+ await wait(4000);
1060
+ const ps = await jsonOf(s, ['device', 'info', 'processes', '--device', phone.id], { timeout: 60_000 });
1061
+ const list = ps.json?.result?.runningProcesses;
1062
+ if (ps.r.code !== 0 || !Array.isArray(list)) return stop('not-confirmed', 'the game was installed and asked to start, and the phone\'s running programs could not be read to see it', { ...on, built: true, installed: true, fix: `Look at the phone: ${s.meta.name} should be on its screen.` });
1063
+ const running = list.some((x) => (Number.isFinite(pid) && Number(x?.processIdentifier) === pid) || (where && String(x?.executable ?? '').startsWith(where)));
1064
+ if (!running) return stop('not-running', 'the game was installed and started, and four seconds later it was not among the phone\'s running programs: it closed, or crashed, as it opened', { ...on, built: true, installed: true, fix: `Tap ${s.meta.name} on the phone; if it closes again, open Xcode, Window, Devices and Simulators, the phone, and read its crash log.` });
1065
+ return {
1066
+ ok: true, command, target: 'ios', device: true, stage: 'started', ...on, built: true, installed: true, running: true,
1067
+ notes: [
1068
+ 'a development build signed for this phone only: it stops opening when its profile ends (a week with a free Apple account, a year with a Developer membership), and it is not a build for the store',
1069
+ s.where.url ? `Quick play finds a room only when ${s.where.url} runs @homie-rocks/studio ${STANDALONE_SINCE} or later; a room made or joined by its code works with an older site too` : 'this copy has no address built in: it plays offline with its bots',
1070
+ ],
1071
+ };
1072
+ }
1073
+
868
1074
  export { MISSING, TARGETS };
package/lib/studio.mjs CHANGED
@@ -94,11 +94,31 @@ export function listGames(root) {
94
94
  .sort((a, b) => a.id.localeCompare(b.id));
95
95
  }
96
96
 
97
+ /**
98
+ * RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view has a src/rules.ts; its settings are
99
+ * game.json `"room"` (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build). Any
100
+ * other game is hosted by a player's browser, as before.
101
+ */
102
+ export const isRulesGame = (g) => existsSync(join(g.dir, 'src', 'rules.ts'));
103
+
104
+ const RULES_HEADER = '// Written by `homie-studio build` from this studio\'s games. Do not edit: the next build writes it again.\n';
105
+ /**
106
+ * site/src/rules/index.mjs: the table of this studio's server-hosted games as site/src/worker.mjs imports it (an
107
+ * empty one for a studio with none, which is what a new studio starts with). lib/rules-build.mjs writes the rest.
108
+ */
109
+ export function rulesIndex(ids = []) {
110
+ const lines = [`${RULES_HEADER}// The rules of this studio's server-hosted games, for the Worker (site/src/worker.mjs hands them to hostRules).`];
111
+ ids.forEach((id, i) => lines.push(`import rules${i} from './${id}.mjs';`, `import data${i} from './${id}.data.mjs';`));
112
+ lines.push(`export default {${ids.map((id, i) => `\n ${JSON.stringify(id)}: { rules: rules${i}, ...data${i} },`).join('')}${ids.length ? '\n' : ''}};`);
113
+ return `${lines.join('\n')}\n`;
114
+ }
115
+
97
116
  export function starters() {
98
117
  const dir = join(PACKAGE_ROOT, 'starters');
99
118
  return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
100
119
  const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
101
- return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds };
120
+ // `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
121
+ return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
102
122
  });
103
123
  }
104
124
 
@@ -1692,6 +1692,96 @@
1692
1692
  }
1693
1693
  }
1694
1694
  }
1695
+ },
1696
+ "0.32.1": {
1697
+ "files": {
1698
+ "CLAUDE.md": "ddf2b2a0376202f6",
1699
+ "README.md": "684b3cca8001673c",
1700
+ "HANDOFF.md": "29337e2ace95b2e7",
1701
+ "games/README.md": "5ad32fe80c61f117",
1702
+ "music/README.md": "a3dc042dadd39df8",
1703
+ "videos/README.md": "16babc3437d5545b",
1704
+ "posts/README.md": "37841aae476bd540",
1705
+ "site/README.md": "732894273a32dc3f",
1706
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1707
+ },
1708
+ "sections": {
1709
+ "AGENTS.md": {
1710
+ "(intro)": "214aa46eb3a895d5",
1711
+ "## Layout": "8baf49e4291e7e88",
1712
+ "## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
1713
+ "## The site": "0cb8dd9a6f8a0096",
1714
+ "## Making games": "2da7fa74c8884834",
1715
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1716
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1717
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1718
+ "## Servers and AI seats": "98d08705087a33ec",
1719
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1720
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1721
+ "## Rules": "2e6b29afb85dcf7e",
1722
+ "## Telling Homie": "80b46fdd9e578db8",
1723
+ "## Beta": "9d02638207a26e73"
1724
+ }
1725
+ },
1726
+ "named": {
1727
+ "Homie Arcade": {
1728
+ "sections": {
1729
+ "AGENTS.md": {
1730
+ "## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ },
1736
+ "0.33.0": {
1737
+ "files": {
1738
+ "CLAUDE.md": "ddf2b2a0376202f6",
1739
+ "README.md": "684b3cca8001673c",
1740
+ "HANDOFF.md": "29337e2ace95b2e7",
1741
+ "games/README.md": "5ad32fe80c61f117",
1742
+ "music/README.md": "a3dc042dadd39df8",
1743
+ "videos/README.md": "16babc3437d5545b",
1744
+ "posts/README.md": "37841aae476bd540",
1745
+ "site/README.md": "732894273a32dc3f",
1746
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1747
+ },
1748
+ "sections": {
1749
+ "AGENTS.md": {
1750
+ "(intro)": "214aa46eb3a895d5",
1751
+ "## Layout": "8baf49e4291e7e88",
1752
+ "## Commands (all through the pinned CLI in node_modules)": [
1753
+ "29a04ed3d956610a",
1754
+ "a7f290579585368c"
1755
+ ],
1756
+ "## The site": "0cb8dd9a6f8a0096",
1757
+ "## Making games": "2da7fa74c8884834",
1758
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1759
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1760
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1761
+ "## Servers and AI seats": "98d08705087a33ec",
1762
+ "## Selling things (the shop)": [
1763
+ "3173053462311959",
1764
+ "56225d9c6292d975",
1765
+ "b04b0a2b25f524f2"
1766
+ ],
1767
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1768
+ "## Rules": "2e6b29afb85dcf7e",
1769
+ "## Telling Homie": "80b46fdd9e578db8",
1770
+ "## Beta": "9d02638207a26e73"
1771
+ }
1772
+ },
1773
+ "named": {
1774
+ "Homie Arcade": {
1775
+ "sections": {
1776
+ "AGENTS.md": {
1777
+ "## Commands (all through the pinned CLI in node_modules)": [
1778
+ "5d1c068adda1116b",
1779
+ "7cad8fd10f62fef1"
1780
+ ]
1781
+ }
1782
+ }
1783
+ }
1784
+ }
1695
1785
  }
1696
1786
  }
1697
1787
  }
package/lib/upgrade.mjs CHANGED
@@ -15,8 +15,9 @@
15
15
  * `--diff` prints the difference, so the person can take the template's words by hand.
16
16
  *
17
17
  * Plus the pin: when this CLI is newer than the version package.json pins (run through
18
- * `npx -y --package=<the new tarball> homie-studio upgrade`), --apply pins this version (then `npm install`), and
19
- * studio.json's `homie.studio` says the version the studio is on.
18
+ * `npx -y @homie-rocks/studio@<the new version> upgrade`), --apply pins this version (then `npm install`), and
19
+ * studio.json's `homie.studio` says the version the studio is on. The pin it writes is always the npm registry's:
20
+ * a studio made before 0.10.0 pinned an address on homie.rocks, and its first upgrade moves it to the registry.
20
21
  *
21
22
  * Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
22
23
  * studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
@@ -43,7 +44,7 @@ import { STUDIO_VERSION, packageSpec } from './version.mjs';
43
44
  /** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
44
45
  export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
45
46
  /** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
46
- export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
47
+ export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep', 'site/src/rules/index.mjs'];
47
48
  const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
48
49
  /** The name and slug stand-ins the history is fingerprinted with. */
49
50
  export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
@@ -124,19 +125,21 @@ function known(history, who = {}) {
124
125
  const semver = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '')) ?? []).slice(1).map(Number);
125
126
  const newer = (a, b) => { const x = semver(a); const y = semver(b); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false; };
126
127
 
127
- /** The @homie-rocks/studio version a package.json spec pins: a homie.rocks tarball, an exact version, or npm:… of one. */
128
+ /**
129
+ * The version in the address a studio made before 0.10.0 pinned (https://<directory>/npm/homie-studio-<version>.tgz),
130
+ * or null. Read for one thing only: so that studio's upgrade knows which version it is leaving. Nothing writes
131
+ * such an address; its upgrade writes the npm registry's exact version.
132
+ */
133
+ const addressPin = (spec) => /^https?:\/\/.+\/homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(String(spec ?? ''))?.[1] ?? null;
134
+
135
+ /** The @homie-rocks/studio version a package.json spec pins: an exact version, npm:… of one, or an older studio's address. */
128
136
  export function pinnedVersion(spec) {
129
137
  const s = String(spec ?? '');
130
- return /homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(s)?.[1] ?? /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? null;
138
+ return /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? addressPin(s);
131
139
  }
132
- /** The same kind of spec, for this version. */
133
- function specFor(spec, directory) {
134
- const s = String(spec ?? '');
135
- const tar = /^(https?:\/\/.+\/)homie-studio-\d+\.\d+\.\d+\.tgz$/.exec(s);
136
- if (tar) return `${tar[1]}homie-studio-${STUDIO_VERSION}.tgz`;
137
- if (/^npm:@homie-rocks\/studio@/.test(s)) return `npm:@homie-rocks/studio@${STUDIO_VERSION}`;
138
- if (/^=?\d+\.\d+\.\d+$/.test(s)) return STUDIO_VERSION;
139
- return packageSpec(directory);
140
+ /** The pin for this version: the npm registry's exact version (an npm: alias stays an alias). */
141
+ function specFor(spec) {
142
+ return /^npm:@homie-rocks\/studio@/.test(String(spec ?? '')) ? `npm:@homie-rocks/studio@${STUDIO_VERSION}` : packageSpec();
140
143
  }
141
144
 
142
145
  /** A line diff (the longest common run kept), for the plan: ` ` same, `- ` the studio's, `+ ` the template's. */
@@ -183,8 +186,9 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
183
186
  if (pinned && newer(pinned, STUDIO_VERSION)) {
184
187
  return { ok: false, command: 'upgrade', from: pinned, to: STUDIO_VERSION, why: `this studio pins @homie-rocks/studio ${pinned}, newer than this ${STUDIO_VERSION}: run its own copy (npx --no-install homie-studio upgrade)` };
185
188
  }
186
- if (pkg && where && pinned && newer(STUDIO_VERSION, pinned)) {
187
- changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION} (was ${pinned}); then npm install`, from: spec, to: specFor(spec, studio.homie?.directory) });
189
+ // An older studio's address is moved to the registry even when it names this very version: it is no longer served.
190
+ if (pkg && where && pinned && (newer(STUDIO_VERSION, pinned) || addressPin(spec))) {
191
+ changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION}${addressPin(spec) ? ' from the npm registry' : ''} (was ${pinned}${addressPin(spec) ? ', at an address that is no longer served' : ''}); then npm install`, from: spec, to: specFor(spec) });
188
192
  } else if (pkg && where && !pinned) {
189
193
  kept.push({ file: 'package.json', what: `@homie-rocks/studio is "${String(spec).slice(0, 80)}" (not a published version): left as it is` });
190
194
  }
package/lib/version.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The one place @homie-rocks/studio says which version it is and where that exact
3
- * version lives. A studio pins this tarball (package.json + package-lock
3
+ * version lives. A studio pins this version (package.json + package-lock
4
4
  * integrity), so a published game never changes because Homie changed.
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
@@ -8,14 +8,9 @@ import { readFileSync } from 'node:fs';
8
8
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
9
  export const STUDIO_VERSION = pkg.version;
10
10
 
11
- /** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
12
- export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
13
- return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
14
- }
15
-
16
11
  /**
17
12
  * What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
18
13
  * integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
19
- * their default network settings; a directory's /npm/ tarball is not.
14
+ * their default network settings.
20
15
  */
21
16
  export function packageSpec() { return STUDIO_VERSION; }