@cimplify/cli 0.15.3 → 0.16.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.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/add-SXC6RUPQ.mjs +140 -0
- package/dist/{add-KRIIKAS4.mjs → add-XQVQLD3F.mjs} +4 -4
- package/dist/ai-NQH5L4I6.mjs +17 -0
- package/dist/{assets-CWB72PZG.mjs → assets-FZGVOQXH.mjs} +12 -7
- package/dist/{auth-step-up-Z6KOTTV7.mjs → auth-step-up-67LCPDNL.mjs} +5 -5
- package/dist/build-J4ZUIOPX.mjs +625 -0
- package/dist/{cancel-ZHVVILOU.mjs → cancel-VGJHZJ4J.mjs} +4 -4
- package/dist/capture-RAJ2B3BH.mjs +69 -0
- package/dist/chunk-25ZPFHLF.mjs +51 -0
- package/dist/chunk-47ZXSY47.mjs +52 -0
- package/dist/{chunk-O2TO2J4I.mjs → chunk-54J3XR4T.mjs} +2 -2
- package/dist/chunk-5HP2INMY.mjs +10631 -0
- package/dist/chunk-7JR6JCFF.mjs +32 -0
- package/dist/chunk-7PQL7E7K.mjs +68 -0
- package/dist/chunk-AKACUJ3W.mjs +81 -0
- package/dist/chunk-BYNE553A.mjs +2945 -0
- package/dist/{chunk-NYXA6X56.mjs → chunk-CLQYPTJ2.mjs} +31 -11
- package/dist/chunk-CNZAOVSK.mjs +104 -0
- package/dist/chunk-CPBESPWK.mjs +5576 -0
- package/dist/{chunk-3RPR33MO.mjs → chunk-DYWUPHV3.mjs} +11 -7
- package/dist/chunk-EAV2P4N3.mjs +1941 -0
- package/dist/{chunk-UTD34C2H.mjs → chunk-EDZSOL5O.mjs} +2 -4
- package/dist/chunk-EGQIPYUN.mjs +696 -0
- package/dist/{chunk-DMLQBAGC.mjs → chunk-EOO4UOQD.mjs} +1 -1
- package/dist/chunk-EYEMPIMP.mjs +74 -0
- package/dist/chunk-G2ZEFSQJ.mjs +46 -0
- package/dist/{chunk-TH4XENKH.mjs → chunk-I4HNHPEM.mjs} +1 -1
- package/dist/chunk-JEVSFNPT.mjs +363 -0
- package/dist/chunk-JKECUAAV.mjs +2169 -0
- package/dist/chunk-KMHORI4Y.mjs +638 -0
- package/dist/chunk-KNPYBZ6B.mjs +502 -0
- package/dist/{chunk-3QUK5BPY.mjs → chunk-KOD4GBQ5.mjs} +8 -1
- package/dist/chunk-KQNZKV6E.mjs +535 -0
- package/dist/{chunk-2MKZKUVH.mjs → chunk-LLRUOIMU.mjs} +1 -2
- package/dist/{chunk-OLZKNUMK.mjs → chunk-LNMITJ4Z.mjs} +6 -6
- package/dist/chunk-MEM3MDV4.mjs +6463 -0
- package/dist/chunk-NY4YT7O6.mjs +84 -0
- package/dist/{chunk-LZEG3FEY.mjs → chunk-Q6CF4C3E.mjs} +7 -7
- package/dist/chunk-RP66A52P.mjs +86 -0
- package/dist/{chunk-I2UP7YTG.mjs → chunk-SAXGWS2K.mjs} +1 -1
- package/dist/chunk-SCKQ4XSD.mjs +41 -0
- package/dist/chunk-SF44L2OD.mjs +1152 -0
- package/dist/chunk-SGT5IJVW.mjs +28 -0
- package/dist/{chunk-LJEKKFC7.mjs → chunk-SS4JTMIV.mjs} +8 -8
- package/dist/chunk-T4MW3PD4.mjs +1456 -0
- package/dist/chunk-THATLMEX.mjs +1227 -0
- package/dist/{chunk-MLE4MVYC.mjs → chunk-TT4YI33G.mjs} +2 -2
- package/dist/chunk-TYV2SVFN.mjs +101 -0
- package/dist/chunk-VHK7WBO3.mjs +189 -0
- package/dist/chunk-VSA44UKR.mjs +391 -0
- package/dist/chunk-XL54SQJ3.mjs +42 -0
- package/dist/{chunk-5KYR6WJB.mjs → chunk-XQAHREKO.mjs} +17 -1
- package/dist/chunk-ZUCDXRFI.mjs +2011 -0
- package/dist/create-4S2EAUR3.mjs +10 -0
- package/dist/credits-NJOAOPPW.mjs +33 -0
- package/dist/{deploy-STWZ3OSM.mjs → deploy-26Y4UGZZ.mjs} +11 -11
- package/dist/dev-MOYURRWQ.mjs +401 -0
- package/dist/dev-WL43Q7VD.mjs +8 -0
- package/dist/dispatcher.mjs +131 -30
- package/dist/{doctor-3RW3OGZJ.mjs → doctor-E6ETD2GJ.mjs} +12 -20
- package/dist/{domains-FTNK4IKY.mjs → domains-LOTQMVOR.mjs} +6 -6
- package/dist/engine-runtime-fetch-CIE7S7PB.mjs +10 -0
- package/dist/{env-TNCYUAMT.mjs → env-3PDFC5XF.mjs} +7 -7
- package/dist/env-MOTYOPM5.mjs +19 -0
- package/dist/explain-K5QEVWCD.mjs +251 -0
- package/dist/{inspect-X3M7WSTL.mjs → inspect-Q3E2DHYY.mjs} +3 -3
- package/dist/inspect-SJ4WBAXO.mjs +86 -0
- package/dist/introspect-7F34YN5V.mjs +9 -0
- package/dist/kernel-DEGWMHFL.mjs +662 -0
- package/dist/kernel-plugin.js +6862 -0
- package/dist/keys-W5SN3DPK.mjs +71 -0
- package/dist/lines-XGJYTQ7P.mjs +56 -0
- package/dist/{link-76Z6V6CJ.mjs → link-NKUAVNM2.mjs} +6 -6
- package/dist/{lint-DDZ7HUQN.mjs → lint-G4YPQSSO.mjs} +2 -2
- package/dist/{list-2MFOQKKQ.mjs → list-TDR7K2JB.mjs} +3 -3
- package/dist/lock-EJPW2LM3.mjs +57 -0
- package/dist/{login-RLMDEWAL.mjs → login-AYLU7JU7.mjs} +10 -8
- package/dist/{logout-Z57X6HPE.mjs → logout-3M6VMHLH.mjs} +2 -2
- package/dist/{logs-TNDM4RFE.mjs → logs-4EL5SOXN.mjs} +5 -5
- package/dist/logs-4QQGG2XY.mjs +44 -0
- package/dist/mcp-4R2R6E5M.mjs +1077 -0
- package/dist/{mock-EVER34IX.mjs → mock-AK24GSP2.mjs} +2 -2
- package/dist/outdated-JEKCVLTG.mjs +58 -0
- package/dist/port-SBZ7X3EH.mjs +2408 -0
- package/dist/{projects-YL4T24VV.mjs → projects-7JKTC5WJ.mjs} +6 -6
- package/dist/publish-QQCX47HK.mjs +20 -0
- package/dist/remix-NLLYD3BI.mjs +125 -0
- package/dist/repo-Z4G4EW3N.mjs +9 -0
- package/dist/report-M7F4ZYP7.mjs +2 -0
- package/dist/rollback-2WQ2CW7T.mjs +43 -0
- package/dist/{rollback-47VQ4HJK.mjs → rollback-ZGU32HGT.mjs} +6 -6
- package/dist/scene-HXARHUM7.mjs +324 -0
- package/dist/{status-TWPOMWNA.mjs → status-C3ZSLZ7Q.mjs} +5 -5
- package/dist/status-WEQOC25K.mjs +18 -0
- package/dist/test-ZPBWV4VX.mjs +233 -0
- package/dist/testers-A3LMXOJJ.mjs +49 -0
- package/dist/{unlink-H3BO3DOQ.mjs → unlink-4G4FE4RJ.mjs} +2 -2
- package/dist/{update-FLY46XI3.mjs → update-GHKKM5FS.mjs} +4 -4
- package/dist/{whoami-CQPPARRS.mjs → whoami-3QQN6G2A.mjs} +3 -3
- package/dist/workshop-CD74D2LK.mjs +1703 -0
- package/package.json +11 -7
- package/templates/storefront-auto/app/products/[slug]/page.tsx +1 -1
- package/templates/storefront-auto/package.json +1 -1
- package/templates/storefront-bakery/package.json +1 -1
- package/templates/storefront-fashion/app/products/[slug]/page.tsx +1 -1
- package/templates/storefront-fashion/package.json +1 -1
- package/templates/storefront-grocery/package.json +1 -1
- package/templates/storefront-pharmacy/app/products/[slug]/page.tsx +1 -1
- package/templates/storefront-pharmacy/package.json +1 -1
- package/templates/storefront-restaurant/package.json +1 -1
- package/templates/storefront-retail/app/products/[slug]/page.tsx +1 -1
- package/templates/storefront-retail/package.json +1 -1
- package/templates/storefront-services/package.json +1 -1
- package/dist/chunk-ARIDYKLO.mjs +0 -6463
- package/dist/dev-7O65HY6C.mjs +0 -8
- package/dist/explain-DLRUUCNS.mjs +0 -251
- package/dist/introspect-RC265ACF.mjs +0 -9
- package/dist/repo-UUT3AZ3L.mjs +0 -9
|
@@ -0,0 +1,1941 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { materialize, titleFromName } from './chunk-CNZAOVSK.mjs';
|
|
3
|
+
import { MANIFEST_FILE, LOCKFILE, serializeLockfile, currentLockfile } from './chunk-T4MW3PD4.mjs';
|
|
4
|
+
import { quoteTomlString } from './chunk-KQNZKV6E.mjs';
|
|
5
|
+
import { PINNED_SDK_VERSION } from './chunk-AKACUJ3W.mjs';
|
|
6
|
+
import { parseArgs, flagString, flagBool } from './chunk-XQAHREKO.mjs';
|
|
7
|
+
import { CliError, CLI_ERROR_CODE, success, warn, info, dim, result } from './chunk-KOD4GBQ5.mjs';
|
|
8
|
+
import { spawnSync } from 'child_process';
|
|
9
|
+
import { promises } from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { frontier, chemistry, genesis, tycoon, runner, shootout, economyToml, economyManifest, defineEconomy } from '@cimplify/sdk/game/kit';
|
|
12
|
+
|
|
13
|
+
// src/interactives/templates/agents.ts
|
|
14
|
+
var AGENTS_FILE = "AGENTS.md";
|
|
15
|
+
var LOOP = `## The loop
|
|
16
|
+
|
|
17
|
+
Work in small steps and check each one:
|
|
18
|
+
|
|
19
|
+
1. Edit \`src/\`. The client entry is \`src/client.ts\` (\`export default async function main(ctx: Ctx)\`).
|
|
20
|
+
2. \`cimplify build --json\` compiles the project the way publish admission does. Fix every diagnostic: each has \`file\`, \`line\`, \`column\`, \`code\` and \`message\`. Codes starting with \`styles.\` come from the style compiler and carry a did-you-mean.
|
|
21
|
+
3. \`cimplify test\` runs \`tests/\` (vitest) and then the publication gates: build, analysis, types, size, and for games frame time, heap growth, cold start, GC pauses and the game UI checks (text overflow, tap targets, contrast, safe area, text size). A failed gate means publish would be refused. \`cimplify test --visual\` also compares the captures of every \`test/visual\` script with its goldens (\`--update\` rewrites them).
|
|
22
|
+
4. \`cimplify capture [--device phone-landscape] [--frame 120] [--script steps.json]\` renders the game headless with a fixed clock and seed and writes a PNG to \`.cimplify/captures\`; look at it after visual changes.
|
|
23
|
+
5. \`cimplify inspect\` prints the game UI tree, one line per node (rect, text, resolved style, source line), followed by the check findings and a proposed fix for each.
|
|
24
|
+
6. \`cimplify dev\` opens the local simulator for a human to play.
|
|
25
|
+
|
|
26
|
+
Every command takes \`--json\` and prints one \`{ "ok": true, "data": ... }\` or \`{ "ok": false, "error": ... }\` line.
|
|
27
|
+
|
|
28
|
+
MCP: \`cimplify mcp\` serves the same loop as tools (\`build\`, \`test\`, \`capture\`, \`inspect\`, \`docs_search\`, \`api_lookup\`, \`create_project\`). Register it once with \`claude mcp add cimplify -- cimplify mcp\` (Claude Code) or as a stdio server named \`cimplify\` running \`cimplify mcp\` in other agents.
|
|
29
|
+
|
|
30
|
+
Look things up instead of guessing: \`api_lookup\` gives the exact signature, parameters and a runnable example for any SDK symbol, style property, class or condition. The docs index for agents is \`https://cimplify.dev/interactives/llms.txt\` (full text at \`/interactives/llms-full.txt\`, machine-readable API at \`/interactives/api-index.json\`).
|
|
31
|
+
`;
|
|
32
|
+
var UI_CHECKS = `## Input scripts and UI checks
|
|
33
|
+
|
|
34
|
+
\`--script\` plays an input script before \`capture\` or \`inspect\`, and every script in \`test/visual/\` becomes a visual test. Scripts are JSON (or TS with a default export):
|
|
35
|
+
|
|
36
|
+
\`\`\`json
|
|
37
|
+
{
|
|
38
|
+
"devices": ["phone-portrait", "tablet"],
|
|
39
|
+
"seed": 7,
|
|
40
|
+
"steps": [
|
|
41
|
+
{ "wait": 30 },
|
|
42
|
+
{ "tap": { "text": "Play" } },
|
|
43
|
+
{ "drag": { "from": [40, 700], "to": [340, 700], "frames": 20 } },
|
|
44
|
+
{ "key": "Space", "hold": 10 },
|
|
45
|
+
{ "capture": "playing" }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
Steps: \`wait\` (frames), \`wait_ms\`, \`tap\` and \`drag\` (targets are \`[x, y]\` points, \`{ "node": id }\`, \`{ "name": "pauseButton" }\` for the variable the element is bound to, or \`{ "text": "Play" }\`; \`hold\` defaults to 1 frame), \`key\`, \`type\` and \`capture\` (names a screenshot). Goldens live in \`test/visual/goldens/<script>-<capture>-<device>.png\`. \`cimplify dev --record test/visual/run.json\` writes a script from a play session (frame-exact, replayed by \`capture --script\`); captures fix the clock (\`new Date()\` too) and the seed and refuse \`new Worker(...)\`.
|
|
51
|
+
|
|
52
|
+
Readiness: \`capture\` reports \`readiness\` (\`first_frame\`, \`first_frame_ms\`, \`bytes_before_first_play\`, \`bytes_total\`, \`frames\`, \`stall_frames\`, \`stalls\`; a stall is a frame where a needed streamed chunk was not resident). \`cimplify test\` adds a readiness gate when \`test/readiness.json\` (an input script along a recorded path) exists; set \`"max_stalls": 0\` in it to fail on stalls. Builds warn (\`stream.stalls\`) when splat scenery is predicted to stall at mid or low tier bandwidth along \`test/camera.json\` (\`[{ "t": seconds, "at": [x, y, z] }]\`) or a default walk.
|
|
53
|
+
|
|
54
|
+
Text from \`ctx.generate\` can carry a small themeable AI mark: \`generatedText(ui).show(await ctx.generate.text({ prompt, fallback }))\`, or \`rich.set(markup, result.source)\` on a \`richText\`; fallback text never shows it (\`aiMarkTheme({ fill, text })\` restyles it).
|
|
55
|
+
|
|
56
|
+
The \`game UI\` gate checks \`text_overflow\`, \`tap_target\` (44 points), \`contrast\` (4.5:1, 3:1 for large text), \`safe_area\` and \`text_size\` (11 points). Fix the finding; when a node is deliberately exempt, say why with a typed reason from \`@cimplify/sdk/interactives\`:
|
|
57
|
+
|
|
58
|
+
\`\`\`ts
|
|
59
|
+
exemptUi(ui.text("v1.0.3", "text-[9px] text-muted"), "text_size", "legal_fine_print");
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
Reasons per check: \`text_overflow\`: \`intentional_clip\`, \`marquee\`, \`decorative\`. \`tap_target\`: \`expanded_hit_area\`, \`dense_grid\`, \`secondary_control\`, \`decorative\`. \`contrast\`: \`decorative\`, \`disabled\`, \`logo\`, \`inside_image\`. \`safe_area\`: \`full_bleed_background\`, \`dismissible_overlay\`, \`decorative\`. \`text_size\`: \`badge_counter\`, \`legal_fine_print\`, \`decorative\`. Any other reason is a type error.
|
|
63
|
+
`;
|
|
64
|
+
var PROJECT = `## Project rules
|
|
65
|
+
|
|
66
|
+
- \`interactive.toml\` is the manifest. Every ctx scope you call needs a \`[capabilities]\` entry with the reason shown to the player. Every external destination needs a \`[[providers]]\` entry.
|
|
67
|
+
- \`cimplify.lock\` pins the SDK and libraries. Never edit it by hand; \`cimplify build\` updates it.
|
|
68
|
+
- Import only \`@cimplify/sdk/interactives\`, \`@cimplify/sdk/game\`, \`@cimplify/sdk/game/kit\`, \`@cimplify/sdk/game/kit/canvas\` (and the kit template entries) plus relative files. Node built-ins, URLs and other npm packages are refused.
|
|
69
|
+
- No \`fetch\`, \`XMLHttpRequest\`, \`WebSocket\`, \`eval\`, \`new Function\`, string timers, dynamic \`import()\` or \`require\`. Network goes through \`ctx\` and declared providers.
|
|
70
|
+
- TypeScript runs in strict mode and every type error blocks the build.
|
|
71
|
+
`;
|
|
72
|
+
var GENERATIVE = `## Scenes and remixes
|
|
73
|
+
|
|
74
|
+
- \`scene(name, spec)\` from \`@cimplify/sdk/game\` declares a layout as objects and relations; \`cimplify build\` compiles every scene and reports its diagnostics (\`scene.*\` codes) with the rest.
|
|
75
|
+
- \`cimplify scene solve <file> [--json] [--seed n] [--export name]\` solves a scene deterministically and prints where every object landed and which relations it could not meet.
|
|
76
|
+
- \`cimplify scene resolve <file> [--json] [--out assets.json] [--tier low|mid|high] [--no-generate]\` turns each kind the scene uses into a Workshop asset, retrieval first and generation as the fallback.
|
|
77
|
+
- \`cimplify remix <template> [--set slot=value]... [--parent remix.json] [--out remix.json]\` swaps a slotted template's theme, characters, scenes, music or rules into a content-addressed remix manifest; with no \`--set\` it lists the slots and what fits them.
|
|
78
|
+
`;
|
|
79
|
+
var STYLING = `## Game UI styling
|
|
80
|
+
|
|
81
|
+
\`const ui = ctx.ui.game();\` returns the engine game UI. Build nodes once, root them, then update them.
|
|
82
|
+
|
|
83
|
+
\`\`\`ts
|
|
84
|
+
const s = ui.styles({
|
|
85
|
+
card: { padding: 12, radius: 16, fill: tokens.surface, scale: { default: 1, ":pressed": 0.95 } },
|
|
86
|
+
wide: { direction: { default: "column", "@landscape": "row" } },
|
|
87
|
+
bar: (ratio: number) => ({ width: \`\${ratio * 100}%\` as const }),
|
|
88
|
+
});
|
|
89
|
+
const card = ui.box(ui.props(s.card, s.wide));
|
|
90
|
+
const bar = ui.box(ui.props(s.bar(0.4), "h-2 rounded-full bg-success"));
|
|
91
|
+
card.append(bar);
|
|
92
|
+
ui.root(card, { anchor: "top", offset: [0, 16] });
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
- Three equivalent forms: a plain object (\`ui.box({ padding: 12 })\`), a sheet (\`ui.styles({...})\` merged with \`ui.props(...)\`), or a class string (\`ui.box("p-3 rounded-xl bg-surface pressed:scale-95")\`). All compile at build time to the same style block.
|
|
96
|
+
- \`ui.props(a, b, ...)\` takes up to eight arguments (styles, class strings, objects, themes, \`false\`, \`null\`). Later values win per property; a shorthand replaces longhands set before it.
|
|
97
|
+
- Conditions: \`:pressed\` (alias \`:active\`), \`:focus\`, \`:disabled\` follow the node and its ancestors; \`@landscape\`, \`@portrait\`, \`@safe\`, \`@sm\` (640 points), \`@lg\` (1024 points) come from \`ui.environment({ width, height, safe })\`. In classes they are prefixes: \`landscape:pressed:opacity-50\`.
|
|
98
|
+
- Tokens: \`tokens.surface\`, \`raised\`, \`border\`, \`accent\`, \`onAccent\`, \`text\`, \`muted\`, \`track\`, \`danger\`, \`success\`, \`gold\` (classes use kebab case: \`bg-on-accent\`). Theme with \`defineVars\`, \`createTheme\`, \`ui.theme(theme)\` or per subtree with \`ui.props(theme, ...)\`.
|
|
99
|
+
- Kit widgets: \`panel\`, \`iconButton\`, \`progressBar\`, \`currencyCounter\`, \`tabs\`, \`itemCard\`, \`toast\`, \`dialogueBox\`, \`worldLabel\`, themed with \`theme({...})\`. Widgets with \`update(dt)\` need it called every frame.
|
|
100
|
+
- \`rotate\` in objects is radians; \`rotate-45\` in classes is degrees. Colours are \`#rgb\`, \`#rgba\`, \`#rrggbb\`, \`#rrggbbaa\`, an RGBA number or a token.
|
|
101
|
+
- Full reference: every property, class family, condition and widget with a runnable example, in the UI reference pages and through \`api_lookup\` (try \`padding\`, \`rounded-xl\`, \`:pressed\`, \`progressBar\`).
|
|
102
|
+
`;
|
|
103
|
+
var NATIVE_UI = `## UI
|
|
104
|
+
|
|
105
|
+
Native mini apps render GenUI trees with \`ctx.ui.render(tree)\` and present sheets with \`ctx.ui.open_sheet\`. There is no DOM. Keep trees small and re-render only when state changes.
|
|
106
|
+
`;
|
|
107
|
+
var PERFORMANCE_GAME = `## Performance rules (the gates measure these)
|
|
108
|
+
|
|
109
|
+
- Zero allocation per frame. \`fixedUpdate\` and \`update\` must not create objects: no \`new\`, array or object literals, spreads, template strings, string concatenation, \`.map\`, \`.filter\` or closures. Create typed arrays, \`Vec3\` and \`Quat\` scratch values, materials, UI nodes and styles once in \`enter()\` or a constructor and reuse them.
|
|
110
|
+
- No closures in hot loops. Write plain \`for\` loops over typed arrays; pass data, not callbacks.
|
|
111
|
+
- Simulation goes in \`fixedUpdate\` (fixed step, deterministic), presentation in \`update\`. Use \`game.random\`, never \`Math.random\`, and the \`detSin\`/\`detCos\` helpers in simulation that must replay.
|
|
112
|
+
- Hundreds or thousands of similar things belong in an entity table (\`game.table\`): bulk op first, kernel second, column loops third.
|
|
113
|
+
- Hot \`for\`, \`for-of\` and \`while\` loops over typed arrays, table columns (\`table.f32("x")\`) or ECS columns are lowered to kernels by the build automatically, with exact JavaScript results (checked bit for bit at build time). Keep their bodies numeric: arithmetic, comparisons, ternaries, \`Math.min/max/abs/floor/ceil/round/sqrt/fround\`, typed-array reads and writes at \`[i]\` or \`[i + k]\`, local numbers, and accumulators like \`sum += x\`. Calls, allocation, strings, \`break\` and reading another iteration's element keep a loop in JavaScript; the build's \`kernel.lower_missed\` warning names the line and the fix. \`cimplify capture\` or \`test\` records which loops are hot (\`.cimplify/loops.json\`) and \`cimplify inspect\` lists them. Opt a file or function out with \`no_lower = ["src/file.ts#fn"]\` under \`[kernels]\` in interactive.toml; \`exact_math = true\` there also lowers \`Math.sin/cos/tan/asin/acos/atan/atan2/exp/log/log2/pow\` and makes them identical on every device.
|
|
114
|
+
- UI: never create elements, call \`ui.styles\` or build class strings inside the frame loop. Update existing nodes with \`.text()\`, \`.number()\` and \`.style(precompiledStyle)\`. Dynamic style functions (\`s.bar(ratio)\`) write into one preallocated style, so calling them per frame is free. \`ui.props\` caches by argument identity, so pass the same compiled styles each time.
|
|
115
|
+
`;
|
|
116
|
+
var PERFORMANCE_NATIVE = `## Performance rules
|
|
117
|
+
|
|
118
|
+
- Do work in response to events, not on timers. Keep allocations out of handlers that fire often.
|
|
119
|
+
- Avoid re-rendering the whole tree for a single value; render once and change only what moved.
|
|
120
|
+
`;
|
|
121
|
+
var MISTAKES_GAME = `## Common mistakes
|
|
122
|
+
|
|
123
|
+
- Nothing appears: the node was never passed to \`ui.root(...)\` (or appended to a rooted node), or it has no size or fill.
|
|
124
|
+
- \`top\`/\`left\` do nothing without \`position: "absolute"\`.
|
|
125
|
+
- CSS names that do not exist here: \`background\`, \`border\`, \`boxShadow\`, \`display: "grid"\`, \`margin: "auto"\`. Use \`fill\`, \`borderWidth\` with \`borderColor\`, \`shadow\`, flex layout and \`justify\`/\`alignItems\`.
|
|
126
|
+
- Building class strings at run time (\`\` \`w-\${n}\` \`\`): the compiler cannot check them and they recompile on every call. Use a dynamic style function or \`.style({ width: n })\`.
|
|
127
|
+
- A dynamic percentage needs \`as const\` (\`\` width: \`\${p}%\` as const \`\`), otherwise the sheet entry is not callable.
|
|
128
|
+
- Misspelt classes or properties fail the build with \`styles.unknown_class\` or \`styles.unknown_property\`; read the did-you-mean.
|
|
129
|
+
- Allocating in \`update\` to format text: use \`element.number(value, { separator: "," })\` instead of building strings.
|
|
130
|
+
- Forgetting to call \`update(game.clock)\` on toasts, counters, dialogue boxes and the animated widgets (timers, damage numbers, particles, tooltips, results and loading screens), so they never animate or hide. Pass the clock, not a number: a fractional dt allocates on every call.
|
|
131
|
+
- Tap targets under 44 points, text under 12 points and HUD edges inside the safe area get flagged by style checks; use \`@safe\` and \`safe_area\` roots.
|
|
132
|
+
- Importing \`three\`, \`phaser\` or an engine backend; the platform picks the engine.
|
|
133
|
+
- Calling a ctx scope with no \`[capabilities]\` entry, or reaching the network directly.
|
|
134
|
+
`;
|
|
135
|
+
var MISTAKES_NATIVE = `## Common mistakes
|
|
136
|
+
|
|
137
|
+
- Calling a ctx scope with no \`[capabilities]\` entry, or reaching the network directly.
|
|
138
|
+
- Editing \`cimplify.lock\` by hand.
|
|
139
|
+
- Payment or credential wording outside \`ctx.wallet\` and host checkout; admission refuses it.
|
|
140
|
+
`;
|
|
141
|
+
function agentsGuide(title, profile) {
|
|
142
|
+
const game2 = profile === "canvas";
|
|
143
|
+
const intro = `# AGENTS.md: ${title}
|
|
144
|
+
|
|
145
|
+
This is a Cimplify Interactive: ${game2 ? "a canvas game built on the Cimplify game API" : "a native mini app built on GenUI"}, written in TypeScript against \`@cimplify/sdk\` and built, tested and published with the \`cimplify\` CLI. This file tells coding agents how to work on it.
|
|
146
|
+
`;
|
|
147
|
+
const sections = game2 ? [intro, LOOP, UI_CHECKS, PROJECT, GENERATIVE, STYLING, PERFORMANCE_GAME, MISTAKES_GAME] : [intro, LOOP, PROJECT, NATIVE_UI, PERFORMANCE_NATIVE, MISTAKES_NATIVE];
|
|
148
|
+
return sections.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/interactives/templates/engine-files.ts
|
|
152
|
+
var C_GAME = `#define CIMPLIFY_IMPLEMENTATION
|
|
153
|
+
#include <cimplify.h>
|
|
154
|
+
#include <GLES3/gl3.h>
|
|
155
|
+
#include <emscripten/html5.h>
|
|
156
|
+
|
|
157
|
+
static const char *VERTEX_SHADER =
|
|
158
|
+
"#version 300 es\\n"
|
|
159
|
+
"uniform vec4 placement;\\n"
|
|
160
|
+
"void main() {\\n"
|
|
161
|
+
" vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));\\n"
|
|
162
|
+
" gl_Position = vec4(placement.xy + (corner * 2.0 - 1.0) * placement.zw, 0.0, 1.0);\\n"
|
|
163
|
+
"}\\n";
|
|
164
|
+
|
|
165
|
+
static const char *FRAGMENT_SHADER =
|
|
166
|
+
"#version 300 es\\n"
|
|
167
|
+
"precision mediump float;\\n"
|
|
168
|
+
"uniform vec4 tint;\\n"
|
|
169
|
+
"out vec4 color;\\n"
|
|
170
|
+
"void main() { color = tint; }\\n";
|
|
171
|
+
|
|
172
|
+
enum { QUAD_VERTICES = 4, SYNC_BUFFER_BYTES = 512, FLASH_MICROS = 400000 };
|
|
173
|
+
|
|
174
|
+
static GLint placement_location;
|
|
175
|
+
static GLint tint_location;
|
|
176
|
+
static float placement[4];
|
|
177
|
+
static float tint[4];
|
|
178
|
+
static int32_t sync_call;
|
|
179
|
+
static int32_t flash_micros;
|
|
180
|
+
static char sync_buffer[SYNC_BUFFER_BYTES];
|
|
181
|
+
|
|
182
|
+
static GLuint compile(GLenum type, const char *source) {
|
|
183
|
+
GLuint shader = glCreateShader(type);
|
|
184
|
+
glShaderSource(shader, 1, &source, 0);
|
|
185
|
+
glCompileShader(shader);
|
|
186
|
+
return shader;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
void cimplify_start(void) {
|
|
190
|
+
EmscriptenWebGLContextAttributes attributes;
|
|
191
|
+
emscripten_webgl_init_context_attributes(&attributes);
|
|
192
|
+
attributes.majorVersion = 2;
|
|
193
|
+
attributes.antialias = 0;
|
|
194
|
+
attributes.alpha = 0;
|
|
195
|
+
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = emscripten_webgl_create_context("#canvas", &attributes);
|
|
196
|
+
emscripten_webgl_make_context_current(context);
|
|
197
|
+
GLuint program = glCreateProgram();
|
|
198
|
+
glAttachShader(program, compile(GL_VERTEX_SHADER, VERTEX_SHADER));
|
|
199
|
+
glAttachShader(program, compile(GL_FRAGMENT_SHADER, FRAGMENT_SHADER));
|
|
200
|
+
glLinkProgram(program);
|
|
201
|
+
glUseProgram(program);
|
|
202
|
+
placement_location = glGetUniformLocation(program, "placement");
|
|
203
|
+
tint_location = glGetUniformLocation(program, "tint");
|
|
204
|
+
GLuint vertices;
|
|
205
|
+
glGenVertexArrays(1, &vertices);
|
|
206
|
+
glBindVertexArray(vertices);
|
|
207
|
+
glClearColor(0.05f, 0.07f, 0.12f, 1.0f);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
void cimplify_frame(int32_t delta_micros) {
|
|
211
|
+
const cimplify_input *input = cimplify_input_state();
|
|
212
|
+
if (input->width <= 0 || input->height <= 0) return;
|
|
213
|
+
if (input->pointer_presses > 0 && sync_call == 0) sync_call = cimplify_call("clock.sync", "[]");
|
|
214
|
+
if (sync_call != 0 && cimplify_status(sync_call) != CIMPLIFY_PENDING) {
|
|
215
|
+
if (cimplify_status(sync_call) == CIMPLIFY_OK) flash_micros = FLASH_MICROS;
|
|
216
|
+
cimplify_take(sync_call, sync_buffer, SYNC_BUFFER_BYTES);
|
|
217
|
+
sync_call = 0;
|
|
218
|
+
}
|
|
219
|
+
flash_micros = flash_micros > delta_micros ? flash_micros - delta_micros : 0;
|
|
220
|
+
float size = 0.12f + (float)flash_micros / (float)FLASH_MICROS * 0.08f;
|
|
221
|
+
placement[0] = (float)input->pointer_x / (float)input->width * 2.0f - 1.0f;
|
|
222
|
+
placement[1] = 1.0f - (float)input->pointer_y / (float)input->height * 2.0f;
|
|
223
|
+
placement[2] = size * (float)input->height / (float)input->width;
|
|
224
|
+
placement[3] = size;
|
|
225
|
+
tint[0] = input->pointer_down ? 1.0f : 0.2f;
|
|
226
|
+
tint[1] = 0.8f;
|
|
227
|
+
tint[2] = 0.5f;
|
|
228
|
+
tint[3] = 1.0f;
|
|
229
|
+
glViewport(0, 0, input->width, input->height);
|
|
230
|
+
glClear(GL_COLOR_BUFFER_BIT);
|
|
231
|
+
glUniform4fv(placement_location, 1, placement);
|
|
232
|
+
glUniform4fv(tint_location, 1, tint);
|
|
233
|
+
glDrawArrays(GL_TRIANGLE_STRIP, 0, QUAD_VERTICES);
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
236
|
+
var RUST_CARGO = `[package]
|
|
237
|
+
name = "game"
|
|
238
|
+
version = "0.1.0"
|
|
239
|
+
edition = "2024"
|
|
240
|
+
|
|
241
|
+
[lib]
|
|
242
|
+
crate-type = ["cdylib"]
|
|
243
|
+
|
|
244
|
+
[dependencies]
|
|
245
|
+
interactives-ctx = "0.1"
|
|
246
|
+
`;
|
|
247
|
+
var RUST_GAME = `#![no_std]
|
|
248
|
+
|
|
249
|
+
use interactives_ctx as ctx;
|
|
250
|
+
|
|
251
|
+
const WIDTH: usize = 256;
|
|
252
|
+
const HEIGHT: usize = 144;
|
|
253
|
+
const BYTES: usize = WIDTH * HEIGHT * 4;
|
|
254
|
+
const SQUARE: i32 = 12;
|
|
255
|
+
const FLASH_MICROS: i32 = 400_000;
|
|
256
|
+
|
|
257
|
+
struct Game {
|
|
258
|
+
frame: [u8; BYTES],
|
|
259
|
+
time_micros: u32,
|
|
260
|
+
flash_micros: i32,
|
|
261
|
+
sync: Option<ctx::Call>,
|
|
262
|
+
reply: [u8; 512],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static mut GAME: Game = Game { frame: [0; BYTES], time_micros: 0, flash_micros: 0, sync: None, reply: [0; 512] };
|
|
266
|
+
|
|
267
|
+
#[panic_handler]
|
|
268
|
+
fn panic(_: &core::panic::PanicInfo) -> ! {
|
|
269
|
+
core::arch::wasm32::unreachable()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn game() -> &'static mut Game {
|
|
273
|
+
unsafe { &mut *core::ptr::addr_of_mut!(GAME) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn start() {}
|
|
277
|
+
|
|
278
|
+
fn frame(delta_micros: i32) {
|
|
279
|
+
let game = game();
|
|
280
|
+
let input = ctx::input();
|
|
281
|
+
game.time_micros = game.time_micros.wrapping_add(delta_micros as u32);
|
|
282
|
+
if input.pointer_presses > 0 && game.sync.is_none() {
|
|
283
|
+
game.sync = Some(ctx::request(ctx::CTX, "clock.sync", "[]"));
|
|
284
|
+
}
|
|
285
|
+
if let Some(call) = game.sync {
|
|
286
|
+
match call.status() {
|
|
287
|
+
ctx::Status::Pending => {}
|
|
288
|
+
status => {
|
|
289
|
+
if status == ctx::Status::Ready {
|
|
290
|
+
game.flash_micros = FLASH_MICROS;
|
|
291
|
+
}
|
|
292
|
+
let _ = call.take(&mut game.reply);
|
|
293
|
+
game.sync = None;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
game.flash_micros = (game.flash_micros - delta_micros).max(0);
|
|
298
|
+
let (px, py) = if input.width > 0 && input.height > 0 {
|
|
299
|
+
(input.pointer_x * WIDTH as i32 / input.width, input.pointer_y * HEIGHT as i32 / input.height)
|
|
300
|
+
} else {
|
|
301
|
+
(WIDTH as i32 / 2, HEIGHT as i32 / 2)
|
|
302
|
+
};
|
|
303
|
+
let shade = (game.time_micros >> 14) as u8;
|
|
304
|
+
let square = SQUARE + game.flash_micros * SQUARE / FLASH_MICROS;
|
|
305
|
+
for (index, pixel) in game.frame.chunks_exact_mut(4).enumerate() {
|
|
306
|
+
let x = (index % WIDTH) as i32;
|
|
307
|
+
let y = (index / WIDTH) as i32;
|
|
308
|
+
let inside = (x - px).abs() < square && (y - py).abs() < square;
|
|
309
|
+
pixel[0] = if inside { 230 } else { (x as u8).wrapping_add(shade) >> 2 };
|
|
310
|
+
pixel[1] = if inside { 200 } else { (y as u8) >> 1 };
|
|
311
|
+
pixel[2] = if inside { if input.pointer_down != 0 { 255 } else { 120 } } else { 60 };
|
|
312
|
+
pixel[3] = 255;
|
|
313
|
+
}
|
|
314
|
+
ctx::present(&game.frame, WIDTH as u32, HEIGHT as u32);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
ctx::game!(start, frame);
|
|
318
|
+
`;
|
|
319
|
+
var GODOT_PROJECT = `config_version=5
|
|
320
|
+
|
|
321
|
+
[application]
|
|
322
|
+
|
|
323
|
+
config/name="__TITLE__"
|
|
324
|
+
run/main_scene="res://main.tscn"
|
|
325
|
+
config/features=PackedStringArray("4.7", "GL Compatibility")
|
|
326
|
+
|
|
327
|
+
[display]
|
|
328
|
+
|
|
329
|
+
window/stretch/mode="canvas_items"
|
|
330
|
+
|
|
331
|
+
[rendering]
|
|
332
|
+
|
|
333
|
+
renderer/rendering_method="gl_compatibility"
|
|
334
|
+
renderer/rendering_method.mobile="gl_compatibility"
|
|
335
|
+
`;
|
|
336
|
+
var GODOT_MAIN_SCENE = `[gd_scene load_steps=2 format=3]
|
|
337
|
+
|
|
338
|
+
[ext_resource type="Script" path="res://main.gd" id="1"]
|
|
339
|
+
|
|
340
|
+
[node name="Main" type="Node2D"]
|
|
341
|
+
script = ExtResource("1")
|
|
342
|
+
`;
|
|
343
|
+
var GODOT_MAIN_SCRIPT = `extends Node2D
|
|
344
|
+
|
|
345
|
+
const SIZE := 96.0
|
|
346
|
+
const SPIN := 1.5
|
|
347
|
+
const BADGE_PACK := "extras"
|
|
348
|
+
const BADGE_SCENE := "res://extras/badge.tscn"
|
|
349
|
+
|
|
350
|
+
var _angle := 0.0
|
|
351
|
+
var _flash := 0.0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
func _ready() -> void:
|
|
355
|
+
var cimplify := get_node_or_null("/root/Cimplify")
|
|
356
|
+
if cimplify == null:
|
|
357
|
+
return
|
|
358
|
+
if await cimplify.load_pack(BADGE_PACK):
|
|
359
|
+
var badge: PackedScene = load(BADGE_SCENE)
|
|
360
|
+
add_child(badge.instantiate())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
func _process(delta: float) -> void:
|
|
364
|
+
_angle += delta * SPIN
|
|
365
|
+
_flash = maxf(_flash - delta, 0.0)
|
|
366
|
+
queue_redraw()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
func _unhandled_input(event: InputEvent) -> void:
|
|
370
|
+
var pressed := (event is InputEventScreenTouch or event is InputEventMouseButton) and event.is_pressed()
|
|
371
|
+
if pressed:
|
|
372
|
+
_sync()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
func _sync() -> void:
|
|
376
|
+
var cimplify := get_node_or_null("/root/Cimplify")
|
|
377
|
+
if cimplify == null:
|
|
378
|
+
return
|
|
379
|
+
var result: Dictionary = await cimplify.call_ctx("clock.sync")
|
|
380
|
+
if result.get("ok", false):
|
|
381
|
+
_flash = 1.0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
func _draw() -> void:
|
|
385
|
+
draw_set_transform(get_viewport_rect().size * 0.5, _angle)
|
|
386
|
+
draw_rect(Rect2(-SIZE * 0.5, -SIZE * 0.5, SIZE, SIZE), Color(0.1 + _flash * 0.8, 0.8, 0.5))
|
|
387
|
+
`;
|
|
388
|
+
var GODOT_BADGE_SCENE = `[gd_scene format=3]
|
|
389
|
+
|
|
390
|
+
[node name="Badge" type="ColorRect"]
|
|
391
|
+
offset_left = 16.0
|
|
392
|
+
offset_top = 16.0
|
|
393
|
+
offset_right = 48.0
|
|
394
|
+
offset_bottom = 48.0
|
|
395
|
+
color = Color(0.95, 0.75, 0.2, 1)
|
|
396
|
+
`;
|
|
397
|
+
|
|
398
|
+
// src/interactives/templates/engines.ts
|
|
399
|
+
var ENGINE_TEMPLATE_NAMES = ["wasm-c", "wasm-rust", "godot"];
|
|
400
|
+
var ENGINE_TEMPLATES = {
|
|
401
|
+
"wasm-c": {
|
|
402
|
+
description: "A C game compiled to WebAssembly by Cimplify, drawing with WebGL2 and reaching ctx through cimplify.h",
|
|
403
|
+
game: "wasm",
|
|
404
|
+
engine: `language = "c"
|
|
405
|
+
sources = ["native/game.c"]
|
|
406
|
+
memory = "16 MiB"
|
|
407
|
+
max_memory = "64 MiB"
|
|
408
|
+
`,
|
|
409
|
+
minDeviceTier: "low",
|
|
410
|
+
downloadSize: "1 MiB",
|
|
411
|
+
gitignore: "node_modules\n.cimplify\n",
|
|
412
|
+
files: [{ path: "native/game.c", content: C_GAME }]
|
|
413
|
+
},
|
|
414
|
+
"wasm-rust": {
|
|
415
|
+
description: "A Rust game compiled to raw WebAssembly by Cimplify, presenting a pixel frame and reaching ctx through interactives-ctx",
|
|
416
|
+
game: "wasm",
|
|
417
|
+
engine: `language = "rust"
|
|
418
|
+
crate = "native/game"
|
|
419
|
+
memory = "2 MiB"
|
|
420
|
+
max_memory = "16 MiB"
|
|
421
|
+
`,
|
|
422
|
+
minDeviceTier: "low",
|
|
423
|
+
downloadSize: "1 MiB",
|
|
424
|
+
gitignore: "node_modules\n.cimplify\ntarget\n",
|
|
425
|
+
files: [
|
|
426
|
+
{ path: "native/game/Cargo.toml", content: RUST_CARGO },
|
|
427
|
+
{ path: "native/game/src/lib.rs", content: RUST_GAME }
|
|
428
|
+
]
|
|
429
|
+
},
|
|
430
|
+
godot: {
|
|
431
|
+
description: "A Godot 4 game exported by Cimplify, reaching ctx through the Cimplify autoload and streaming a second pack after play starts",
|
|
432
|
+
game: "godot",
|
|
433
|
+
engine: `project = "game"
|
|
434
|
+
runtime = "2d"
|
|
435
|
+
|
|
436
|
+
[[engine.packs]]
|
|
437
|
+
name = "extras"
|
|
438
|
+
files = ["res://extras/*"]
|
|
439
|
+
`,
|
|
440
|
+
minDeviceTier: "high",
|
|
441
|
+
downloadSize: "48 MiB",
|
|
442
|
+
gitignore: "node_modules\n.cimplify\n.godot\n",
|
|
443
|
+
files: [
|
|
444
|
+
{ path: "game/project.godot", content: GODOT_PROJECT },
|
|
445
|
+
{ path: "game/main.tscn", content: GODOT_MAIN_SCENE },
|
|
446
|
+
{ path: "game/main.gd", content: GODOT_MAIN_SCRIPT },
|
|
447
|
+
{ path: "game/extras/badge.tscn", content: GODOT_BADGE_SCENE }
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
function packageJson(name) {
|
|
452
|
+
return `${JSON.stringify({ name, private: true, scripts: { dev: "cimplify dev" }, dependencies: { "@cimplify/sdk": `^${PINNED_SDK_VERSION}` } }, null, 2)}
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
455
|
+
function manifest(title, template) {
|
|
456
|
+
return `[interactive]
|
|
457
|
+
name = ${quoteTomlString(title)}
|
|
458
|
+
description = ${quoteTomlString(template.description)}
|
|
459
|
+
category = "games"
|
|
460
|
+
|
|
461
|
+
[profile]
|
|
462
|
+
kind = "canvas"
|
|
463
|
+
game = "${template.game}"
|
|
464
|
+
|
|
465
|
+
[engine]
|
|
466
|
+
${template.engine}
|
|
467
|
+
[rating]
|
|
468
|
+
violence = "none"
|
|
469
|
+
sexuality = "none"
|
|
470
|
+
language = "none"
|
|
471
|
+
fear = "none"
|
|
472
|
+
simulated_gambling = false
|
|
473
|
+
|
|
474
|
+
[delivery]
|
|
475
|
+
min_device_tier = "${template.minDeviceTier}"
|
|
476
|
+
download_size = "${template.downloadSize}"
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
function isEngineTemplate(value) {
|
|
480
|
+
return ENGINE_TEMPLATE_NAMES.indexOf(value) >= 0;
|
|
481
|
+
}
|
|
482
|
+
function renderEngineTemplate(template, name) {
|
|
483
|
+
const spec = ENGINE_TEMPLATES[template];
|
|
484
|
+
const title = titleFromName(name);
|
|
485
|
+
return [
|
|
486
|
+
{ path: MANIFEST_FILE, content: manifest(title, spec) },
|
|
487
|
+
{ path: LOCKFILE, content: serializeLockfile(currentLockfile()) },
|
|
488
|
+
{ path: "package.json", content: packageJson(name) },
|
|
489
|
+
{ path: ".gitignore", content: spec.gitignore },
|
|
490
|
+
...spec.files.map((file) => ({ path: file.path, content: file.content.replaceAll("__TITLE__", title) }))
|
|
491
|
+
];
|
|
492
|
+
}
|
|
493
|
+
var FRONTIER_FEATURES = ["audio", "ai"];
|
|
494
|
+
var CLIENT = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
495
|
+
import { firstFrame, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
496
|
+
import { frontier } from "@cimplify/sdk/game/kit";
|
|
497
|
+
|
|
498
|
+
const WORLD = "my-planet";
|
|
499
|
+
|
|
500
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
501
|
+
const launched = firstFrame();
|
|
502
|
+
let ask: (() => void) | null = null;
|
|
503
|
+
const session = await frontier.joinFrontierPlanet(ctx, { world_id: WORLD, spawn: { kind: "last_location" } }, () => ask?.());
|
|
504
|
+
const { scene } = session;
|
|
505
|
+
ask = () => {
|
|
506
|
+
void (async () => {
|
|
507
|
+
const result = await ctx.ui.open_sheet({
|
|
508
|
+
title: "Invent something",
|
|
509
|
+
tree: { type: "Input", props: { label: "Describe your invention", value: "$description", placeholder: "A water wheel that powers a mill" } },
|
|
510
|
+
dismissible: true,
|
|
511
|
+
});
|
|
512
|
+
if (result.status !== "submitted") return;
|
|
513
|
+
const value = result.value as { description?: unknown } | null;
|
|
514
|
+
const description = typeof value?.description === "string" ? value.description : "";
|
|
515
|
+
if (description.trim() === "") return;
|
|
516
|
+
const run = await frontier.requestInvention(ctx.ai, {
|
|
517
|
+
description,
|
|
518
|
+
materials: ["stone", "wood", "clay", "iron", "copper", "glass"],
|
|
519
|
+
techs: ["fire"],
|
|
520
|
+
requestKey: String(ctx.clock.now()),
|
|
521
|
+
});
|
|
522
|
+
scene.commands.invent(run);
|
|
523
|
+
})();
|
|
524
|
+
};
|
|
525
|
+
await runCanvasGame(ctx, {
|
|
526
|
+
scene,
|
|
527
|
+
onFrame(game, nowMs) {
|
|
528
|
+
session.onFrame(nowMs);
|
|
529
|
+
launched.onFrame(game, nowMs);
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
`;
|
|
534
|
+
var CONFORMANCE = `import { createGame } from "@cimplify/sdk/game";
|
|
535
|
+
import { platformBackend, PREPARED_PLATFORM } from "@cimplify/sdk/game/kit/canvas";
|
|
536
|
+
import { frontier } from "@cimplify/sdk/game/kit";
|
|
537
|
+
|
|
538
|
+
export default async function createFrameHook(platform = PREPARED_PLATFORM): Promise<(frame: number) => void> {
|
|
539
|
+
const game = await createGame({ backend: await platformBackend(platform), seed: 1 });
|
|
540
|
+
const scene = new frontier.FrontierPlanet({ link: new frontier.ReplayFrontierLink([]), region: frontier.regionKey(0, 0, 0, 0), player: 0 });
|
|
541
|
+
game.addScene(scene);
|
|
542
|
+
game.start(scene.name);
|
|
543
|
+
return () => game.runFrame();
|
|
544
|
+
}
|
|
545
|
+
`;
|
|
546
|
+
var TEST = `import { describe, expect, it } from "vitest";
|
|
547
|
+
import { frontier } from "@cimplify/sdk/game/kit";
|
|
548
|
+
|
|
549
|
+
describe("frontier planet", () => {
|
|
550
|
+
it("encodes a till command for the planet room", () => {
|
|
551
|
+
const writer = new frontier.CommandWriter();
|
|
552
|
+
const length = frontier.encodeCommand({ kind: "till", region: frontier.regionKey(0, 0, 1, 2), x: 10, z: 20 }, writer);
|
|
553
|
+
expect(length).toBe(12);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("asks the declared invention prompt for structured output", () => {
|
|
557
|
+
const request = frontier.inventionRequest({ description: "a windmill", materials: ["wood"], techs: ["fire"], requestKey: "k" });
|
|
558
|
+
expect(request.prompt).toBe(frontier.INVENT_PROMPT);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
`;
|
|
562
|
+
var FRONTIER_TEMPLATE = {
|
|
563
|
+
description: "An empty planet you and your friends develop: gather, invent, build machines, farm and automate",
|
|
564
|
+
category: "games",
|
|
565
|
+
profile: "canvas",
|
|
566
|
+
game: true,
|
|
567
|
+
capabilities: {
|
|
568
|
+
ai: frontier.AI_CAPABILITY,
|
|
569
|
+
world: "Keeps your planet running while you are away and shares it with friends",
|
|
570
|
+
identity: "Shows who built what"
|
|
571
|
+
},
|
|
572
|
+
extraManifest: `${frontier.frontierAiSection()}
|
|
573
|
+
[world]
|
|
574
|
+
simulation = "frontier"
|
|
575
|
+
`,
|
|
576
|
+
minDeviceTier: "low",
|
|
577
|
+
engineFeatures: FRONTIER_FEATURES,
|
|
578
|
+
files: [
|
|
579
|
+
{ path: "src/client.ts", content: CLIENT },
|
|
580
|
+
{ path: "src/conformance.ts", content: CONFORMANCE },
|
|
581
|
+
{ path: "tests/frontier.test.ts", content: TEST }
|
|
582
|
+
]
|
|
583
|
+
};
|
|
584
|
+
var CHEMISTRY_FEATURES = ["ai", "chemistry_1"];
|
|
585
|
+
var CLIENT2 = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
586
|
+
import { firstFrame, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
587
|
+
import { chemistry } from "@cimplify/sdk/game/kit";
|
|
588
|
+
|
|
589
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
590
|
+
const launched = firstFrame();
|
|
591
|
+
let menu: ((which: "tool" | "invent") => void) | null = null;
|
|
592
|
+
const scene = new chemistry.ChemistrySandbox({ width: 160, height: 120, onMenu: (which) => menu?.(which) });
|
|
593
|
+
const game = await runCanvasGame(ctx, {
|
|
594
|
+
scene,
|
|
595
|
+
onFrame(game, nowMs) {
|
|
596
|
+
launched.onFrame(game, nowMs);
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
menu = (which) => {
|
|
600
|
+
if (which === "tool") {
|
|
601
|
+
scene.nextTool();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
void (async () => {
|
|
605
|
+
const result = await ctx.ui.open_sheet({
|
|
606
|
+
title: "Invent a reaction",
|
|
607
|
+
tree: { type: "Input", props: { label: "What should happen?", value: "$idea", placeholder: "Rust dissolves in vinegar and fizzes" } },
|
|
608
|
+
dismissible: true,
|
|
609
|
+
});
|
|
610
|
+
if (result.status !== "submitted") return;
|
|
611
|
+
const value = result.value as { idea?: unknown } | null;
|
|
612
|
+
const idea = typeof value?.idea === "string" ? value.idea.trim() : "";
|
|
613
|
+
if (idea === "") return;
|
|
614
|
+
const outcome = await chemistry.proposeReaction(game.chemistry, ctx.ai, { description: idea, requestKey: String(ctx.clock.now()) });
|
|
615
|
+
ctx.ui.toast(outcome.ok ? "Your reaction is now part of this world" : outcome.reason);
|
|
616
|
+
})();
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
`;
|
|
620
|
+
var CONFORMANCE2 = `import { createGame } from "@cimplify/sdk/game";
|
|
621
|
+
import { platformBackend, PREPARED_PLATFORM } from "@cimplify/sdk/game/kit/canvas";
|
|
622
|
+
import { chemistry } from "@cimplify/sdk/game/kit";
|
|
623
|
+
|
|
624
|
+
export default async function createFrameHook(platform = PREPARED_PLATFORM): Promise<(frame: number) => void> {
|
|
625
|
+
const game = await createGame({ backend: await platformBackend(platform), seed: 1 });
|
|
626
|
+
const scene = new chemistry.ChemistrySandbox({ width: 96, height: 72 });
|
|
627
|
+
game.addScene(scene);
|
|
628
|
+
game.start(scene.name);
|
|
629
|
+
return () => game.runFrame();
|
|
630
|
+
}
|
|
631
|
+
`;
|
|
632
|
+
var TEST2 = `import { describe, expect, it } from "vitest";
|
|
633
|
+
import { chemistry } from "@cimplify/sdk/game/kit";
|
|
634
|
+
|
|
635
|
+
describe("chemistry sandbox", () => {
|
|
636
|
+
it("turns a model proposal into checked definition lines", () => {
|
|
637
|
+
const text = chemistry.proposalText({
|
|
638
|
+
reaction: { name: "hydrogen_pop", reactants: [{ material: "H2", count: 2 }, { material: "O2", count: 1, phase: "gas" }], products: [{ material: "H2O", count: 2 }], above_k: 850 },
|
|
639
|
+
});
|
|
640
|
+
expect(text).toContain("reaction hydrogen_pop: 2 H2 + 1 O2@g -> 2 H2O");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("cycles through the tools", () => {
|
|
644
|
+
const scene = new chemistry.ChemistrySandbox();
|
|
645
|
+
expect(scene.nextTool()).toBe("water");
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
`;
|
|
649
|
+
var CHEMISTRY_TEMPLATE = {
|
|
650
|
+
description: "A falling-sand chemistry sandbox: mix, heat, burn, dissolve, smelt and invent reactions",
|
|
651
|
+
category: "games",
|
|
652
|
+
profile: "canvas",
|
|
653
|
+
game: true,
|
|
654
|
+
capabilities: {
|
|
655
|
+
ai: chemistry.CHEMISTRY_AI_CAPABILITY
|
|
656
|
+
},
|
|
657
|
+
extraManifest: chemistry.chemistryAiSection(),
|
|
658
|
+
minDeviceTier: "low",
|
|
659
|
+
engineFeatures: CHEMISTRY_FEATURES,
|
|
660
|
+
files: [
|
|
661
|
+
{ path: "src/client.ts", content: CLIENT2 },
|
|
662
|
+
{ path: "src/conformance.ts", content: CONFORMANCE2 },
|
|
663
|
+
{ path: "tests/chemistry.test.ts", content: TEST2 }
|
|
664
|
+
]
|
|
665
|
+
};
|
|
666
|
+
var GENESIS_FEATURES = ["audio", "ai"];
|
|
667
|
+
var CLIENT3 = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
668
|
+
import { firstFrame, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
669
|
+
import { genesis } from "@cimplify/sdk/game/kit";
|
|
670
|
+
|
|
671
|
+
const WORLD = "my-world";
|
|
672
|
+
const REALM = 0;
|
|
673
|
+
|
|
674
|
+
async function words(ctx: Ctx, title: string, label: string, placeholder: string): Promise<string> {
|
|
675
|
+
const result = await ctx.ui.open_sheet({
|
|
676
|
+
title,
|
|
677
|
+
tree: { type: "Input", props: { label, value: "$words", placeholder } },
|
|
678
|
+
dismissible: true,
|
|
679
|
+
});
|
|
680
|
+
if (result.status !== "submitted") return "";
|
|
681
|
+
const value = result.value as { words?: unknown } | null;
|
|
682
|
+
return typeof value?.words === "string" ? value.words.trim() : "";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
type World = InstanceType<typeof genesis.GenesisWorld>;
|
|
686
|
+
|
|
687
|
+
async function review(ctx: Ctx, scene: World): Promise<void> {
|
|
688
|
+
const r = scene.mirror.review;
|
|
689
|
+
if (r === null) return;
|
|
690
|
+
scene.mirror.review = null;
|
|
691
|
+
const lines = [...r.problems.map((p) => "Problem: " + p.text), ...r.lines];
|
|
692
|
+
const result = await ctx.ui.open_sheet({
|
|
693
|
+
title: r.problems.length === 0 ? "Read it back before it becomes law" : "This draft cannot become law yet",
|
|
694
|
+
tree: { type: "Stack", children: lines.map((text) => ({ type: "TextContent", props: { text } })) },
|
|
695
|
+
dismissible: true,
|
|
696
|
+
});
|
|
697
|
+
if (result.status === "submitted" && r.problems.length === 0) scene.commands.enact(r);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
701
|
+
const launched = firstFrame();
|
|
702
|
+
const session = await genesis.joinGenesisWorld(ctx, { world_id: WORLD, spawn: { kind: "default" } }, {
|
|
703
|
+
onLaw(scene) {
|
|
704
|
+
void (async () => {
|
|
705
|
+
const said = await words(ctx, "Write the law", "Your constitution in your own words", "Land belongs to whoever farms it. Seven elders rule, chosen by lot each decade.");
|
|
706
|
+
if (said === "") return;
|
|
707
|
+
const run = await genesis.requestConstitution(ctx.ai, { words: said, world: scene.worldContext(), requestKey: String(ctx.clock.now()) });
|
|
708
|
+
scene.commands.draft(run, REALM);
|
|
709
|
+
})();
|
|
710
|
+
},
|
|
711
|
+
onChronicle(scene) {
|
|
712
|
+
const m = scene.mirror;
|
|
713
|
+
void (async () => {
|
|
714
|
+
const facts = genesis.factsText(m.nature.seed, m.facts.slice(-24), (s) => m.name[s] ?? 0);
|
|
715
|
+
const run = await genesis.requestChronicle(ctx.ai, { facts, realm: scene.worldContext(), requestKey: String(m.state.month) });
|
|
716
|
+
const output = run.result?.output;
|
|
717
|
+
const text = output !== null && output !== undefined && output.kind === "text" ? output.text : "";
|
|
718
|
+
if (text !== "") await ctx.ui.open_sheet({ title: "Chronicle", tree: { type: "TextContent", props: { text } }, dismissible: true });
|
|
719
|
+
})();
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
const { scene } = session;
|
|
723
|
+
await runCanvasGame(ctx, {
|
|
724
|
+
scene,
|
|
725
|
+
onFrame(game, nowMs) {
|
|
726
|
+
session.onFrame(nowMs);
|
|
727
|
+
launched.onFrame(game, nowMs);
|
|
728
|
+
if (scene.mirror.review !== null) void review(ctx, scene);
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
`;
|
|
733
|
+
var CONFORMANCE3 = `import { createGame } from "@cimplify/sdk/game";
|
|
734
|
+
import { platformBackend, PREPARED_PLATFORM } from "@cimplify/sdk/game/kit/canvas";
|
|
735
|
+
import { genesis } from "@cimplify/sdk/game/kit";
|
|
736
|
+
|
|
737
|
+
export default async function createFrameHook(platform = PREPARED_PLATFORM): Promise<(frame: number) => void> {
|
|
738
|
+
const game = await createGame({ backend: await platformBackend(platform), seed: 1 });
|
|
739
|
+
const scene = new genesis.GenesisWorld({ link: new genesis.ReplayGenesisLink([]) });
|
|
740
|
+
game.addScene(scene);
|
|
741
|
+
game.start(scene.name);
|
|
742
|
+
return () => game.runFrame();
|
|
743
|
+
}
|
|
744
|
+
`;
|
|
745
|
+
var TEST3 = `import { describe, expect, it } from "vitest";
|
|
746
|
+
import { genesis } from "@cimplify/sdk/game/kit";
|
|
747
|
+
|
|
748
|
+
describe("genesis world", () => {
|
|
749
|
+
it("encodes a blessing decree for the world room", () => {
|
|
750
|
+
const writer = new genesis.ByteWriter();
|
|
751
|
+
const length = genesis.encodeDecree(
|
|
752
|
+
{ target: { kind: "settlement", settlement: 0 }, power: { kind: "bless", mortalityQ16: 45_000, fertilityQ16: 80_000, months: 24 } },
|
|
753
|
+
writer,
|
|
754
|
+
);
|
|
755
|
+
expect(length).toBe(36);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("asks the declared constitution prompt for structured output", () => {
|
|
759
|
+
const request = genesis.constitutionRequest({ words: "Nobody may be forced to worship.", world: "", requestKey: "k" });
|
|
760
|
+
expect(request.prompt).toBe(genesis.CONSTITUTION_PROMPT);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
`;
|
|
764
|
+
var GENESIS_TEMPLATE = {
|
|
765
|
+
description: "Play god on a world you write: shape nature, write its constitution, bless, curse and watch generations live out history",
|
|
766
|
+
category: "games",
|
|
767
|
+
profile: "canvas",
|
|
768
|
+
game: true,
|
|
769
|
+
capabilities: {
|
|
770
|
+
ai: genesis.AI_CAPABILITY,
|
|
771
|
+
world: "Keeps your world's history running while you are away and shares it with friends",
|
|
772
|
+
identity: "Shows which god made which decree"
|
|
773
|
+
},
|
|
774
|
+
extraManifest: `${genesis.genesisAiSection()}
|
|
775
|
+
[world]
|
|
776
|
+
simulation = "genesis"
|
|
777
|
+
`,
|
|
778
|
+
minDeviceTier: "low",
|
|
779
|
+
engineFeatures: GENESIS_FEATURES,
|
|
780
|
+
files: [
|
|
781
|
+
{ path: "src/client.ts", content: CLIENT3 },
|
|
782
|
+
{ path: "src/conformance.ts", content: CONFORMANCE3 },
|
|
783
|
+
{ path: "tests/genesis.test.ts", content: TEST3 }
|
|
784
|
+
]
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// src/interactives/templates/games.ts
|
|
788
|
+
var CANVAS_BOOT = `export async function acquireCanvas(ctx: Ctx): Promise<{ canvas: HTMLCanvasElement; g: CanvasRenderingContext2D; width: number; height: number }> {
|
|
789
|
+
const surface = await ctx.graphics.acquire_surface({ api: "canvas2d", transparent: false, pixel_ratio_cap: 2, offscreen: false });
|
|
790
|
+
const canvas = surface.canvas;
|
|
791
|
+
if (!(canvas instanceof HTMLCanvasElement)) throw new Error("this template draws on an on-screen canvas");
|
|
792
|
+
const g = canvas.getContext("2d");
|
|
793
|
+
if (!g) throw new Error("2d canvas is unavailable");
|
|
794
|
+
return { canvas, g, width: surface.width, height: surface.height };
|
|
795
|
+
}
|
|
796
|
+
`;
|
|
797
|
+
var RACING_SIM = `export const CARS = 6;
|
|
798
|
+
export const LAP_METRES = 1000;
|
|
799
|
+
const ACCEL = 0.000012;
|
|
800
|
+
const DRAG = 0.0009;
|
|
801
|
+
const AI_THROTTLE_BASE = 0.55;
|
|
802
|
+
const AI_THROTTLE_SPREAD = 0.06;
|
|
803
|
+
|
|
804
|
+
export class RaceSim {
|
|
805
|
+
readonly position = new Float64Array(CARS);
|
|
806
|
+
readonly speed = new Float64Array(CARS);
|
|
807
|
+
readonly laps = new Int32Array(CARS);
|
|
808
|
+
private readonly aiThrottle = new Float64Array(CARS);
|
|
809
|
+
|
|
810
|
+
constructor() {
|
|
811
|
+
for (let i = 1; i < CARS; i++) this.aiThrottle[i] = AI_THROTTLE_BASE + i * AI_THROTTLE_SPREAD;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
step(stepMs: number, throttle: number): void {
|
|
815
|
+
this.aiThrottle[0] = throttle;
|
|
816
|
+
for (let i = 0; i < CARS; i++) {
|
|
817
|
+
const v = this.speed[i] + (this.aiThrottle[i] * ACCEL - DRAG * this.speed[i] * this.speed[i]) * stepMs;
|
|
818
|
+
this.speed[i] = v > 0 ? v : 0;
|
|
819
|
+
let p = this.position[i] + this.speed[i] * stepMs;
|
|
820
|
+
if (p >= LAP_METRES) {
|
|
821
|
+
p -= LAP_METRES;
|
|
822
|
+
this.laps[i]++;
|
|
823
|
+
}
|
|
824
|
+
this.position[i] = p;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
rank(car: number): number {
|
|
829
|
+
const mine = this.laps[car] * LAP_METRES + this.position[car];
|
|
830
|
+
let ahead = 0;
|
|
831
|
+
for (let i = 0; i < CARS; i++) if (i !== car && this.laps[i] * LAP_METRES + this.position[i] > mine) ahead++;
|
|
832
|
+
return ahead + 1;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
`;
|
|
836
|
+
var RACING_CLIENT = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
837
|
+
import { CARS, LAP_METRES, RaceSim } from "./sim";
|
|
838
|
+
|
|
839
|
+
const COLORS = ["#10b981", "#f59e0b", "#3b82f6", "#ef4444", "#a855f7", "#14b8a6"];
|
|
840
|
+
const TRACK = "#1f2937";
|
|
841
|
+
const BACKGROUND = "#0b1220";
|
|
842
|
+
const TEXT = "#f9fafb";
|
|
843
|
+
|
|
844
|
+
${CANVAS_BOOT}
|
|
845
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
846
|
+
const { canvas, g, width, height } = await acquireCanvas(ctx);
|
|
847
|
+
const sim = new RaceSim();
|
|
848
|
+
let throttle = 0;
|
|
849
|
+
let label = "";
|
|
850
|
+
let shownLap = -1;
|
|
851
|
+
let shownRank = -1;
|
|
852
|
+
canvas.addEventListener("pointerdown", () => {
|
|
853
|
+
throttle = 1;
|
|
854
|
+
});
|
|
855
|
+
canvas.addEventListener("pointerup", () => {
|
|
856
|
+
throttle = 0;
|
|
857
|
+
});
|
|
858
|
+
const lane = height / (CARS + 1);
|
|
859
|
+
ctx.game.loop.start({
|
|
860
|
+
update(stepMs) {
|
|
861
|
+
sim.step(stepMs, throttle);
|
|
862
|
+
},
|
|
863
|
+
render() {
|
|
864
|
+
g.fillStyle = BACKGROUND;
|
|
865
|
+
g.fillRect(0, 0, width, height);
|
|
866
|
+
g.fillStyle = TRACK;
|
|
867
|
+
g.fillRect(0, lane / 2, width, lane * CARS);
|
|
868
|
+
for (let i = 0; i < CARS; i++) {
|
|
869
|
+
g.fillStyle = COLORS[i];
|
|
870
|
+
g.fillRect((sim.position[i] / LAP_METRES) * (width - 24), lane * (i + 0.6), 24, lane * 0.6);
|
|
871
|
+
}
|
|
872
|
+
const rank = sim.rank(0);
|
|
873
|
+
if (sim.laps[0] !== shownLap || rank !== shownRank) {
|
|
874
|
+
shownLap = sim.laps[0];
|
|
875
|
+
shownRank = rank;
|
|
876
|
+
label = "Lap " + (shownLap + 1) + " P" + rank + " hold to accelerate";
|
|
877
|
+
}
|
|
878
|
+
g.fillStyle = TEXT;
|
|
879
|
+
g.fillText(label, 12, 16);
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
ctx.on_dispose(() => ctx.game.loop.stop());
|
|
883
|
+
}
|
|
884
|
+
`;
|
|
885
|
+
var RACING_CONFORMANCE = `import { RaceSim } from "./sim";
|
|
886
|
+
|
|
887
|
+
const STEP_MS = 1000 / 60;
|
|
888
|
+
const PULSE_FRAMES = 64;
|
|
889
|
+
|
|
890
|
+
export default function createFrameHook(): (frame: number) => void {
|
|
891
|
+
const sim = new RaceSim();
|
|
892
|
+
return (frame) => {
|
|
893
|
+
sim.step(STEP_MS, frame % PULSE_FRAMES < PULSE_FRAMES / 2 ? 1 : 0);
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
`;
|
|
897
|
+
var RACING_TEST = `import { describe, expect, it } from "vitest";
|
|
898
|
+
import { RaceSim } from "../src/sim";
|
|
899
|
+
|
|
900
|
+
describe("RaceSim", () => {
|
|
901
|
+
it("completes laps when the player holds the throttle", () => {
|
|
902
|
+
const sim = new RaceSim();
|
|
903
|
+
for (let i = 0; i < 60 * 120; i++) sim.step(1000 / 60, 1);
|
|
904
|
+
expect(sim.laps[0]).toBeGreaterThan(0);
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
`;
|
|
908
|
+
var SPORT_SIM = `export const GROUND = 0;
|
|
909
|
+
export const GOAL_X = 30;
|
|
910
|
+
const GRAVITY = -0.00003;
|
|
911
|
+
const RESTITUTION = 0.55;
|
|
912
|
+
const KICK_UP = 0.018;
|
|
913
|
+
const KICK_FORWARD = 0.012;
|
|
914
|
+
const FRICTION = 0.9995;
|
|
915
|
+
const X = 0;
|
|
916
|
+
const Y = 1;
|
|
917
|
+
const VX = 2;
|
|
918
|
+
const VY = 3;
|
|
919
|
+
|
|
920
|
+
export class KickSim {
|
|
921
|
+
readonly state = new Float64Array(4);
|
|
922
|
+
goals = 0;
|
|
923
|
+
kicks = 0;
|
|
924
|
+
|
|
925
|
+
kick(power: number): void {
|
|
926
|
+
if (this.state[Y] > 0.05) return;
|
|
927
|
+
this.state[VY] = KICK_UP * power;
|
|
928
|
+
this.state[VX] = KICK_FORWARD * power;
|
|
929
|
+
this.kicks++;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
step(stepMs: number): void {
|
|
933
|
+
const s = this.state;
|
|
934
|
+
s[VY] += GRAVITY * stepMs;
|
|
935
|
+
s[X] += s[VX] * stepMs;
|
|
936
|
+
s[Y] += s[VY] * stepMs;
|
|
937
|
+
s[VX] *= FRICTION;
|
|
938
|
+
if (s[Y] < GROUND) {
|
|
939
|
+
s[Y] = GROUND;
|
|
940
|
+
s[VY] = -s[VY] * RESTITUTION;
|
|
941
|
+
}
|
|
942
|
+
if (s[X] >= GOAL_X) {
|
|
943
|
+
this.goals++;
|
|
944
|
+
s[X] = 0;
|
|
945
|
+
s[Y] = 0;
|
|
946
|
+
s[VX] = 0;
|
|
947
|
+
s[VY] = 0;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
get x(): number {
|
|
952
|
+
return this.state[X];
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
get y(): number {
|
|
956
|
+
return this.state[Y];
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
`;
|
|
960
|
+
var SPORT_CLIENT = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
961
|
+
import { GOAL_X, KickSim } from "./sim";
|
|
962
|
+
|
|
963
|
+
const PITCH = "#166534";
|
|
964
|
+
const BALL = "#f9fafb";
|
|
965
|
+
const GOAL = "#facc15";
|
|
966
|
+
const BALL_RADIUS = 8;
|
|
967
|
+
const METRES_TO_PIXELS = 10;
|
|
968
|
+
|
|
969
|
+
${CANVAS_BOOT}
|
|
970
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
971
|
+
const { canvas, g, width, height } = await acquireCanvas(ctx);
|
|
972
|
+
const sim = new KickSim();
|
|
973
|
+
let label = "Tap to kick";
|
|
974
|
+
let shownGoals = 0;
|
|
975
|
+
canvas.addEventListener("pointerdown", () => sim.kick(1));
|
|
976
|
+
const scale = width / (GOAL_X + 4);
|
|
977
|
+
const ground = height - 24;
|
|
978
|
+
ctx.game.loop.start({
|
|
979
|
+
update(stepMs) {
|
|
980
|
+
sim.step(stepMs);
|
|
981
|
+
},
|
|
982
|
+
render() {
|
|
983
|
+
g.fillStyle = PITCH;
|
|
984
|
+
g.fillRect(0, 0, width, height);
|
|
985
|
+
g.fillStyle = GOAL;
|
|
986
|
+
g.fillRect(GOAL_X * scale, ground - 60, 6, 60);
|
|
987
|
+
g.fillStyle = BALL;
|
|
988
|
+
g.beginPath();
|
|
989
|
+
g.arc(sim.x * scale + BALL_RADIUS, ground - sim.y * METRES_TO_PIXELS - BALL_RADIUS, BALL_RADIUS, 0, Math.PI * 2);
|
|
990
|
+
g.fill();
|
|
991
|
+
if (sim.goals !== shownGoals) {
|
|
992
|
+
shownGoals = sim.goals;
|
|
993
|
+
label = "Goals " + shownGoals;
|
|
994
|
+
}
|
|
995
|
+
g.fillText(label, 12, 16);
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
ctx.on_dispose(() => ctx.game.loop.stop());
|
|
999
|
+
}
|
|
1000
|
+
`;
|
|
1001
|
+
var SPORT_CONFORMANCE = `import { KickSim } from "./sim";
|
|
1002
|
+
|
|
1003
|
+
const STEP_MS = 1000 / 60;
|
|
1004
|
+
const KICK_EVERY = 90;
|
|
1005
|
+
|
|
1006
|
+
export default function createFrameHook(): (frame: number) => void {
|
|
1007
|
+
const sim = new KickSim();
|
|
1008
|
+
return (frame) => {
|
|
1009
|
+
if (frame % KICK_EVERY === 0) sim.kick(1);
|
|
1010
|
+
sim.step(STEP_MS);
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
`;
|
|
1014
|
+
var SPORT_TEST = `import { describe, expect, it } from "vitest";
|
|
1015
|
+
import { KickSim } from "../src/sim";
|
|
1016
|
+
|
|
1017
|
+
describe("KickSim", () => {
|
|
1018
|
+
it("scores after repeated kicks", () => {
|
|
1019
|
+
const sim = new KickSim();
|
|
1020
|
+
for (let i = 0; i < 60 * 60; i++) {
|
|
1021
|
+
if (i % 90 === 0) sim.kick(1);
|
|
1022
|
+
sim.step(1000 / 60);
|
|
1023
|
+
}
|
|
1024
|
+
expect(sim.goals).toBeGreaterThan(0);
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
`;
|
|
1028
|
+
var BOARD_SIM = `export const EMPTY = 0;
|
|
1029
|
+
export const PLAYER = 1;
|
|
1030
|
+
export const COMPUTER = 2;
|
|
1031
|
+
const LINES = new Int8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 3, 6, 1, 4, 7, 2, 5, 8, 0, 4, 8, 2, 4, 6]);
|
|
1032
|
+
const CENTER = 4;
|
|
1033
|
+
|
|
1034
|
+
export class BoardSim {
|
|
1035
|
+
readonly cells = new Int8Array(9);
|
|
1036
|
+
winner = EMPTY;
|
|
1037
|
+
moves = 0;
|
|
1038
|
+
|
|
1039
|
+
reset(): void {
|
|
1040
|
+
this.cells.fill(EMPTY);
|
|
1041
|
+
this.winner = EMPTY;
|
|
1042
|
+
this.moves = 0;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
get finished(): boolean {
|
|
1046
|
+
return this.winner !== EMPTY || this.moves === 9;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
play(cell: number): boolean {
|
|
1050
|
+
if (this.finished || cell < 0 || cell > 8 || this.cells[cell] !== EMPTY) return false;
|
|
1051
|
+
this.place(cell, PLAYER);
|
|
1052
|
+
if (!this.finished) this.place(this.reply(), COMPUTER);
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private place(cell: number, who: number): void {
|
|
1057
|
+
this.cells[cell] = who;
|
|
1058
|
+
this.moves++;
|
|
1059
|
+
if (this.wins(who)) this.winner = who;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private wins(who: number): boolean {
|
|
1063
|
+
for (let i = 0; i < LINES.length; i += 3) {
|
|
1064
|
+
if (this.cells[LINES[i]] === who && this.cells[LINES[i + 1]] === who && this.cells[LINES[i + 2]] === who) return true;
|
|
1065
|
+
}
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private completing(who: number): number {
|
|
1070
|
+
for (let i = 0; i < LINES.length; i += 3) {
|
|
1071
|
+
let mine = 0;
|
|
1072
|
+
let open = -1;
|
|
1073
|
+
for (let j = 0; j < 3; j++) {
|
|
1074
|
+
const c = this.cells[LINES[i + j]];
|
|
1075
|
+
if (c === who) mine++;
|
|
1076
|
+
else if (c === EMPTY) open = LINES[i + j];
|
|
1077
|
+
}
|
|
1078
|
+
if (mine === 2 && open >= 0) return open;
|
|
1079
|
+
}
|
|
1080
|
+
return -1;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private reply(): number {
|
|
1084
|
+
const win = this.completing(COMPUTER);
|
|
1085
|
+
if (win >= 0) return win;
|
|
1086
|
+
const block = this.completing(PLAYER);
|
|
1087
|
+
if (block >= 0) return block;
|
|
1088
|
+
if (this.cells[CENTER] === EMPTY) return CENTER;
|
|
1089
|
+
for (let i = 0; i < 9; i++) if (this.cells[i] === EMPTY) return i;
|
|
1090
|
+
return -1;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
`;
|
|
1094
|
+
var BOARD_CLIENT = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
1095
|
+
import { BoardSim, COMPUTER, PLAYER } from "./sim";
|
|
1096
|
+
|
|
1097
|
+
const BACKGROUND = "#111827";
|
|
1098
|
+
const LINE = "#374151";
|
|
1099
|
+
const MARKS = ["", "#10b981", "#f59e0b"];
|
|
1100
|
+
|
|
1101
|
+
${CANVAS_BOOT}
|
|
1102
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1103
|
+
const { canvas, g, width, height } = await acquireCanvas(ctx);
|
|
1104
|
+
const sim = new BoardSim();
|
|
1105
|
+
const size = Math.min(width, height);
|
|
1106
|
+
const cell = size / 3;
|
|
1107
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
1108
|
+
if (sim.finished) {
|
|
1109
|
+
sim.reset();
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const rect = canvas.getBoundingClientRect();
|
|
1113
|
+
const col = Math.floor(((event.clientX - rect.left) / rect.width) * width / cell);
|
|
1114
|
+
const row = Math.floor(((event.clientY - rect.top) / rect.height) * height / cell);
|
|
1115
|
+
if (col < 3 && row < 3) sim.play(row * 3 + col);
|
|
1116
|
+
});
|
|
1117
|
+
ctx.game.loop.start({
|
|
1118
|
+
update() {},
|
|
1119
|
+
render() {
|
|
1120
|
+
g.fillStyle = BACKGROUND;
|
|
1121
|
+
g.fillRect(0, 0, width, height);
|
|
1122
|
+
g.fillStyle = LINE;
|
|
1123
|
+
for (let i = 1; i < 3; i++) {
|
|
1124
|
+
g.fillRect(i * cell - 1, 0, 2, size);
|
|
1125
|
+
g.fillRect(0, i * cell - 1, size, 2);
|
|
1126
|
+
}
|
|
1127
|
+
for (let i = 0; i < 9; i++) {
|
|
1128
|
+
const who = sim.cells[i];
|
|
1129
|
+
if (who !== PLAYER && who !== COMPUTER) continue;
|
|
1130
|
+
g.fillStyle = MARKS[who];
|
|
1131
|
+
g.fillRect((i % 3) * cell + cell * 0.25, Math.floor(i / 3) * cell + cell * 0.25, cell * 0.5, cell * 0.5);
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
ctx.on_dispose(() => ctx.game.loop.stop());
|
|
1136
|
+
}
|
|
1137
|
+
`;
|
|
1138
|
+
var BOARD_CONFORMANCE = `import { BoardSim } from "./sim";
|
|
1139
|
+
|
|
1140
|
+
const STRIDE = 5;
|
|
1141
|
+
|
|
1142
|
+
export default function createFrameHook(): (frame: number) => void {
|
|
1143
|
+
const sim = new BoardSim();
|
|
1144
|
+
return (frame) => {
|
|
1145
|
+
if (sim.finished) sim.reset();
|
|
1146
|
+
sim.play((frame * STRIDE) % 9);
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
`;
|
|
1150
|
+
var BOARD_TEST = `import { describe, expect, it } from "vitest";
|
|
1151
|
+
import { BoardSim, COMPUTER } from "../src/sim";
|
|
1152
|
+
|
|
1153
|
+
describe("BoardSim", () => {
|
|
1154
|
+
it("blocks and then wins against a careless player", () => {
|
|
1155
|
+
const sim = new BoardSim();
|
|
1156
|
+
sim.play(0);
|
|
1157
|
+
sim.play(1);
|
|
1158
|
+
sim.play(6);
|
|
1159
|
+
sim.play(8);
|
|
1160
|
+
expect(sim.winner).toBe(COMPUTER);
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
`;
|
|
1164
|
+
function game(description, sim, client, conformance2, test2) {
|
|
1165
|
+
return {
|
|
1166
|
+
description,
|
|
1167
|
+
category: "games",
|
|
1168
|
+
profile: "canvas",
|
|
1169
|
+
game: true,
|
|
1170
|
+
capabilities: {},
|
|
1171
|
+
files: [
|
|
1172
|
+
{ path: "src/sim.ts", content: sim },
|
|
1173
|
+
{ path: "src/client.ts", content: client },
|
|
1174
|
+
{ path: "src/conformance.ts", content: conformance2 },
|
|
1175
|
+
{ path: "tests/sim.test.ts", content: test2 }
|
|
1176
|
+
]
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
var GAME_TEMPLATES = {
|
|
1180
|
+
racing: game("Hold to accelerate and race five rivals", RACING_SIM, RACING_CLIENT, RACING_CONFORMANCE, RACING_TEST),
|
|
1181
|
+
sport: game("Tap to kick the ball into the goal", SPORT_SIM, SPORT_CLIENT, SPORT_CONFORMANCE, SPORT_TEST),
|
|
1182
|
+
board: game("Noughts and crosses against the computer", BOARD_SIM, BOARD_CLIENT, BOARD_CONFORMANCE, BOARD_TEST)
|
|
1183
|
+
};
|
|
1184
|
+
var BOX_SCENE_FEATURES = ["audio"];
|
|
1185
|
+
var PENALTY_FEATURES = ["audio", "physics"];
|
|
1186
|
+
var DRIVING_FEATURES = ["audio", "physics", "models", "images"];
|
|
1187
|
+
var DANFO_CONTENT = ["cimplify/danfo-bus"];
|
|
1188
|
+
function conformance(game2) {
|
|
1189
|
+
return `import { createTemplateHarness, platformBackend, PREPARED_PLATFORM, ${game2.module} } from "@cimplify/sdk/game/kit/canvas";
|
|
1190
|
+
|
|
1191
|
+
const SEED = 1;
|
|
1192
|
+
|
|
1193
|
+
export default async function createFrameHook(platform = PREPARED_PLATFORM): Promise<(frame: number) => void> {
|
|
1194
|
+
const harness = await createTemplateHarness(${game2.module}.${game2.template}, await platformBackend(platform), SEED);
|
|
1195
|
+
return (frame) => harness.frame(frame);
|
|
1196
|
+
}
|
|
1197
|
+
`;
|
|
1198
|
+
}
|
|
1199
|
+
function test(game2) {
|
|
1200
|
+
return `import { describe, expect, it } from "vitest";
|
|
1201
|
+
import { createTemplateHarness, kitPublicationGates, platformBackend, PREPARED_PLATFORM, ${game2.module} } from "@cimplify/sdk/game/kit/canvas";
|
|
1202
|
+
|
|
1203
|
+
const template = ${game2.module}.${game2.template};
|
|
1204
|
+
|
|
1205
|
+
describe("${game2.testTitle}", () => {
|
|
1206
|
+
it("makes progress under the playtest bot", async () => {
|
|
1207
|
+
const harness = await createTemplateHarness(template, await platformBackend(PREPARED_PLATFORM), 1);
|
|
1208
|
+
for (let i = 0; i < ${game2.testFrames}; i++) harness.frame(i);
|
|
1209
|
+
expect(template.progress(harness.scene)).toBeGreaterThanOrEqual(${game2.testProgress});
|
|
1210
|
+
harness.game.dispose();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("passes the money gates", () => {
|
|
1214
|
+
expect(kitPublicationGates(template.economy).filter((g) => !g.passed)).toEqual([]);
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
`;
|
|
1218
|
+
}
|
|
1219
|
+
var BOOT = `import type { Ctx } from "@cimplify/sdk/interactives";
|
|
1220
|
+
`;
|
|
1221
|
+
var SHOOTOUT_CLIENT = `${BOOT}import { firstFrame, FriendChallenges, Leaderboard, runCanvasGame, shootout } from "@cimplify/sdk/game/kit/canvas";
|
|
1222
|
+
|
|
1223
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1224
|
+
const launched = firstFrame();
|
|
1225
|
+
const services = launched.reached.then(async () => ({
|
|
1226
|
+
me: await ctx.identity.current(),
|
|
1227
|
+
board: await Leaderboard.open((request) => ctx.open_counter(request), { name: "best-series", scope: "interactive" }),
|
|
1228
|
+
challenges: new FriendChallenges(ctx.social),
|
|
1229
|
+
}));
|
|
1230
|
+
const scene = new shootout.PenaltyShootout({
|
|
1231
|
+
seriesEnded(goals) {
|
|
1232
|
+
void services.then(({ me, board, challenges }) => {
|
|
1233
|
+
void board.submit(me.participant_id, goals);
|
|
1234
|
+
if (goals === shootout.SHOOTOUT.kicksPerSeries) void challenges.postResult(goals);
|
|
1235
|
+
});
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
await runCanvasGame(ctx, { scene, onFrame: launched.onFrame });
|
|
1239
|
+
}
|
|
1240
|
+
`;
|
|
1241
|
+
var RUNNER_CLIENT = `${BOOT}import { defineEconomy, firstFrame, Leaderboard, Ledger, runCanvasGame, runner } from "@cimplify/sdk/game/kit/canvas";
|
|
1242
|
+
|
|
1243
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1244
|
+
const launched = firstFrame();
|
|
1245
|
+
const services = launched.reached.then(async () => ({
|
|
1246
|
+
me: await ctx.identity.current(),
|
|
1247
|
+
ledger: new Ledger(defineEconomy(runner.runnerEconomy), { economy: ctx.game.economy, keys: ctx.id }),
|
|
1248
|
+
board: await Leaderboard.open((request) => ctx.open_counter(request), { name: "distance", scope: "interactive" }),
|
|
1249
|
+
}));
|
|
1250
|
+
const scene = new runner.EndlessRunner({
|
|
1251
|
+
runEnded(distance, coins) {
|
|
1252
|
+
void services.then(({ me, ledger, board }) => {
|
|
1253
|
+
if (coins > 0) void ledger.earn(runner.runCoins, Math.min(coins, runner.runCoins.maxQuantity));
|
|
1254
|
+
void board.submit(me.participant_id, Math.floor(distance));
|
|
1255
|
+
});
|
|
1256
|
+
},
|
|
1257
|
+
});
|
|
1258
|
+
await runCanvasGame(ctx, { scene, onFrame: launched.onFrame });
|
|
1259
|
+
}
|
|
1260
|
+
`;
|
|
1261
|
+
var FIVE_A_SIDE_CLIENT = `${BOOT}import { fiveASide, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
1262
|
+
|
|
1263
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1264
|
+
await runCanvasGame(ctx, { scene: new fiveASide.FiveASide() });
|
|
1265
|
+
}
|
|
1266
|
+
`;
|
|
1267
|
+
var LUDO_CLIENT = `${BOOT}import { ludo, runCanvasGame, StatusLine } from "@cimplify/sdk/game/kit/canvas";
|
|
1268
|
+
|
|
1269
|
+
const COPY = { yourTurn: "Your turn in Ludo", waiting: "Waiting for the others", won: "You won Ludo", finished: "Game over" };
|
|
1270
|
+
const SEED_RANGE = 0x3fffffff;
|
|
1271
|
+
|
|
1272
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1273
|
+
const seed = Math.floor(ctx.random.cosmetic() * SEED_RANGE);
|
|
1274
|
+
const scene = new ludo.LudoScene({ seats: 4, seed, status: new StatusLine(ctx.program), copy: COPY });
|
|
1275
|
+
await runCanvasGame(ctx, { scene });
|
|
1276
|
+
}
|
|
1277
|
+
`;
|
|
1278
|
+
var WORD_CLIENT = `${BOOT}import { dayIndex, firstFrame, newStreak, openSaveSlots, parseStreak, recordPlay, runCanvasGame, StatusLine, streakStatus, utcOffsetMinutes, word } from "@cimplify/sdk/game/kit/canvas";
|
|
1279
|
+
|
|
1280
|
+
const COPY = { atRisk: (days: number) => "Keep your " + days + " day word streak" };
|
|
1281
|
+
|
|
1282
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1283
|
+
const launched = firstFrame();
|
|
1284
|
+
const status = new StatusLine(ctx.program);
|
|
1285
|
+
const today = (): number => dayIndex(ctx.clock.now(), utcOffsetMinutes(ctx.locale.time_zone, ctx.clock.now()));
|
|
1286
|
+
const streaks = launched.reached.then(async () => {
|
|
1287
|
+
const slots = await openSaveSlots(ctx, { name: "word-streak", version: 1, initial: () => newStreak(1), parse: parseStreak });
|
|
1288
|
+
const streak = (await slots.load("streak")).value;
|
|
1289
|
+
const nudge = streakStatus(streak, today(), COPY);
|
|
1290
|
+
if (nudge !== null) void status.set(nudge);
|
|
1291
|
+
return { slots, streak };
|
|
1292
|
+
});
|
|
1293
|
+
const scene = new word.WordPuzzle({
|
|
1294
|
+
roundEnded(won) {
|
|
1295
|
+
if (!won) return;
|
|
1296
|
+
void streaks.then(({ slots, streak }) => {
|
|
1297
|
+
recordPlay(streak, today());
|
|
1298
|
+
void status.clear();
|
|
1299
|
+
void slots.save("streak", streak);
|
|
1300
|
+
});
|
|
1301
|
+
},
|
|
1302
|
+
});
|
|
1303
|
+
await runCanvasGame(ctx, { scene, onFrame: launched.onFrame });
|
|
1304
|
+
}
|
|
1305
|
+
`;
|
|
1306
|
+
var TYCOON_CLIENT = `${BOOT}import { Autosave, defineEconomy, firstFrame, Ledger, openSaveSlots, runCanvasGame, tycoon } from "@cimplify/sdk/game/kit/canvas";
|
|
1307
|
+
|
|
1308
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1309
|
+
const ledger = new Ledger(defineEconomy(tycoon.tycoonEconomy), { economy: ctx.game.economy, keys: ctx.id });
|
|
1310
|
+
const scene = new tycoon.JollofTycoon((costs) => new tycoon.LedgerTycoonBank(ledger, costs));
|
|
1311
|
+
let autosave: Autosave<tycoon.TycoonSave> | null = null;
|
|
1312
|
+
const launched = firstFrame(() => autosave?.markDirty());
|
|
1313
|
+
void launched.reached.then(async () => {
|
|
1314
|
+
await ledger.refresh();
|
|
1315
|
+
const slots = await openSaveSlots(ctx, tycoon.tycoonSaveSchema);
|
|
1316
|
+
scene.restore((await slots.load("main")).value, ctx.clock.now());
|
|
1317
|
+
autosave = new Autosave(slots, "main", () => scene.snapshot(ctx.clock.now()), ctx.lifecycle);
|
|
1318
|
+
});
|
|
1319
|
+
ctx.on_dispose(() => autosave?.dispose());
|
|
1320
|
+
await runCanvasGame(ctx, { scene, onFrame: launched.onFrame });
|
|
1321
|
+
}
|
|
1322
|
+
`;
|
|
1323
|
+
var DANFO_CLIENT = `${BOOT}import { danfo, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
1324
|
+
|
|
1325
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1326
|
+
const art = await danfo.takeDanfoArt(ctx.assets);
|
|
1327
|
+
const scene = new danfo.DanfoRoute(null, art);
|
|
1328
|
+
await runCanvasGame(ctx, { scene });
|
|
1329
|
+
}
|
|
1330
|
+
`;
|
|
1331
|
+
var COURIER_CLIENT = `${BOOT}import { courier, runCanvasGame } from "@cimplify/sdk/game/kit/canvas";
|
|
1332
|
+
|
|
1333
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1334
|
+
await runCanvasGame(ctx, { scene: new courier.CourierPlaza() });
|
|
1335
|
+
}
|
|
1336
|
+
`;
|
|
1337
|
+
function kitGame(game2) {
|
|
1338
|
+
return {
|
|
1339
|
+
description: game2.description,
|
|
1340
|
+
category: "games",
|
|
1341
|
+
profile: "canvas",
|
|
1342
|
+
game: true,
|
|
1343
|
+
capabilities: game2.capabilities ?? {},
|
|
1344
|
+
economy: game2.economy ? economyToml(economyManifest(defineEconomy(game2.economy))) : null,
|
|
1345
|
+
minDeviceTier: game2.minDeviceTier,
|
|
1346
|
+
engineFeatures: game2.engineFeatures,
|
|
1347
|
+
suggestedContent: game2.suggestedContent,
|
|
1348
|
+
files: [
|
|
1349
|
+
{ path: "src/client.ts", content: game2.client },
|
|
1350
|
+
{ path: "src/conformance.ts", content: conformance(game2) },
|
|
1351
|
+
{ path: "tests/game.test.ts", content: test(game2) }
|
|
1352
|
+
]
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
var KIT_GAME_TEMPLATES = {
|
|
1356
|
+
shootout: kitGame({
|
|
1357
|
+
description: "Five penalties against the keeper, then challenge your friends",
|
|
1358
|
+
module: "shootout",
|
|
1359
|
+
minDeviceTier: "mid",
|
|
1360
|
+
engineFeatures: PENALTY_FEATURES,
|
|
1361
|
+
template: "penaltyShootoutTemplate",
|
|
1362
|
+
client: SHOOTOUT_CLIENT,
|
|
1363
|
+
testTitle: "penalty shootout",
|
|
1364
|
+
testFrames: 60 * 60,
|
|
1365
|
+
testProgress: 5,
|
|
1366
|
+
capabilities: {
|
|
1367
|
+
identity: "Puts your name on the leaderboard",
|
|
1368
|
+
counter: "Keeps the best series leaderboard",
|
|
1369
|
+
social: "Lets you share a perfect series",
|
|
1370
|
+
"game.economy": "Keeps your tokens and kits safe",
|
|
1371
|
+
wallet: "Lets you buy stars with Cimples"
|
|
1372
|
+
},
|
|
1373
|
+
economy: shootout.shootoutEconomy
|
|
1374
|
+
}),
|
|
1375
|
+
runner: kitGame({
|
|
1376
|
+
description: "Dodge, jump and collect coins for as long as you can",
|
|
1377
|
+
module: "runner",
|
|
1378
|
+
engineFeatures: BOX_SCENE_FEATURES,
|
|
1379
|
+
template: "endlessRunnerTemplate",
|
|
1380
|
+
client: RUNNER_CLIENT,
|
|
1381
|
+
testTitle: "endless runner",
|
|
1382
|
+
testFrames: 60 * 40,
|
|
1383
|
+
testProgress: 250,
|
|
1384
|
+
capabilities: { identity: "Puts your name on the leaderboard", counter: "Keeps the distance leaderboard", "game.economy": "Banks the coins you collect" },
|
|
1385
|
+
economy: runner.runnerEconomy
|
|
1386
|
+
}),
|
|
1387
|
+
"five-a-side": kitGame({
|
|
1388
|
+
description: "Fast five-a-side football against the computer",
|
|
1389
|
+
module: "fiveASide",
|
|
1390
|
+
engineFeatures: BOX_SCENE_FEATURES,
|
|
1391
|
+
template: "fiveASideTemplate",
|
|
1392
|
+
client: FIVE_A_SIDE_CLIENT,
|
|
1393
|
+
testTitle: "five-a-side",
|
|
1394
|
+
testFrames: 60 * 120,
|
|
1395
|
+
testProgress: 2
|
|
1396
|
+
}),
|
|
1397
|
+
ludo: kitGame({
|
|
1398
|
+
description: "Ludo for four, with your turn shown in your chats",
|
|
1399
|
+
module: "ludo",
|
|
1400
|
+
engineFeatures: BOX_SCENE_FEATURES,
|
|
1401
|
+
template: "ludoTemplate",
|
|
1402
|
+
client: LUDO_CLIENT,
|
|
1403
|
+
testTitle: "ludo",
|
|
1404
|
+
testFrames: 60 * 90,
|
|
1405
|
+
testProgress: 4,
|
|
1406
|
+
capabilities: { program: "Shows when it is your turn" }
|
|
1407
|
+
}),
|
|
1408
|
+
word: kitGame({
|
|
1409
|
+
description: "Guess the five-letter word in six tries and keep your streak",
|
|
1410
|
+
module: "word",
|
|
1411
|
+
engineFeatures: BOX_SCENE_FEATURES,
|
|
1412
|
+
template: "wordPuzzleTemplate",
|
|
1413
|
+
client: WORD_CLIENT,
|
|
1414
|
+
testTitle: "word puzzle",
|
|
1415
|
+
testFrames: 60 * 60,
|
|
1416
|
+
testProgress: 3,
|
|
1417
|
+
capabilities: { store: "Remembers your streak", program: "Reminds you to keep your streak" }
|
|
1418
|
+
}),
|
|
1419
|
+
tycoon: kitGame({
|
|
1420
|
+
description: "Grow a jollof empire from a single roadside pot",
|
|
1421
|
+
module: "tycoon",
|
|
1422
|
+
engineFeatures: BOX_SCENE_FEATURES,
|
|
1423
|
+
template: "jollofTycoonTemplate",
|
|
1424
|
+
client: TYCOON_CLIENT,
|
|
1425
|
+
testTitle: "jollof tycoon",
|
|
1426
|
+
testFrames: 60 * 120,
|
|
1427
|
+
testProgress: 500,
|
|
1428
|
+
capabilities: { store: "Saves your empire", "game.economy": "Keeps your coins safe", wallet: "Lets you buy gems with Cimples" },
|
|
1429
|
+
economy: tycoon.tycoonEconomy
|
|
1430
|
+
}),
|
|
1431
|
+
danfo: kitGame({
|
|
1432
|
+
description: "Drive a yellow danfo around a city loop, stop for passengers at Ridgeway Junction, Palm Market, Harbour Gate, Old Mill and Sunrise Estate, and count your fares",
|
|
1433
|
+
module: "danfo",
|
|
1434
|
+
minDeviceTier: "mid",
|
|
1435
|
+
engineFeatures: DRIVING_FEATURES,
|
|
1436
|
+
suggestedContent: DANFO_CONTENT,
|
|
1437
|
+
template: "danfoRouteTemplate",
|
|
1438
|
+
client: DANFO_CLIENT,
|
|
1439
|
+
testTitle: "danfo route",
|
|
1440
|
+
testFrames: 60 * 90,
|
|
1441
|
+
testProgress: 600
|
|
1442
|
+
}),
|
|
1443
|
+
courier: kitGame({
|
|
1444
|
+
description: "Run parcels round a plaza in third person with touch sticks, jumps and a camera that never clips",
|
|
1445
|
+
module: "courier",
|
|
1446
|
+
engineFeatures: PENALTY_FEATURES,
|
|
1447
|
+
template: "courierTemplate",
|
|
1448
|
+
client: COURIER_CLIENT,
|
|
1449
|
+
testTitle: "courier",
|
|
1450
|
+
testFrames: 60 * 60,
|
|
1451
|
+
testProgress: 3
|
|
1452
|
+
})
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
// src/interactives/templates/native.ts
|
|
1456
|
+
var BLANK_CLIENT = `import type { Ctx, UiNode } from "@cimplify/sdk/interactives";
|
|
1457
|
+
|
|
1458
|
+
export function view(name: string): UiNode {
|
|
1459
|
+
return {
|
|
1460
|
+
type: "Card",
|
|
1461
|
+
props: { title: "__TITLE__" },
|
|
1462
|
+
children: [
|
|
1463
|
+
{ type: "TextContent", props: { text: "Hello, " + name, size: "title" } },
|
|
1464
|
+
{ type: "TextContent", props: { text: "Edit src/client.ts and save to reload.", size: "caption" } },
|
|
1465
|
+
],
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1470
|
+
const persona = await ctx.identity.current();
|
|
1471
|
+
ctx.ui.render(view(persona.display_name));
|
|
1472
|
+
}
|
|
1473
|
+
`;
|
|
1474
|
+
var BLANK_TEST = `import { describe, expect, it } from "vitest";
|
|
1475
|
+
import { view } from "../src/client";
|
|
1476
|
+
|
|
1477
|
+
describe("view", () => {
|
|
1478
|
+
it("greets the player by name", () => {
|
|
1479
|
+
expect(JSON.stringify(view("Ama"))).toContain("Hello, Ama");
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
`;
|
|
1483
|
+
var QUIZ_CLIENT = `import type { BoundValue, Ctx, UiNode } from "@cimplify/sdk/interactives";
|
|
1484
|
+
|
|
1485
|
+
export interface Question {
|
|
1486
|
+
readonly prompt: string;
|
|
1487
|
+
readonly options: readonly string[];
|
|
1488
|
+
readonly answer: number;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export const QUESTIONS: readonly Question[] = [
|
|
1492
|
+
{ prompt: "Which planet is closest to the sun?", options: ["Venus", "Mercury", "Mars"], answer: 1 },
|
|
1493
|
+
{ prompt: "How many sides does a hexagon have?", options: ["5", "6", "8"], answer: 1 },
|
|
1494
|
+
{ prompt: "What is the capital of Ghana?", options: ["Kumasi", "Tamale", "Accra"], answer: 2 },
|
|
1495
|
+
];
|
|
1496
|
+
|
|
1497
|
+
export const ANSWER_ACTION = "answer";
|
|
1498
|
+
export const QUESTION_FORM = "question";
|
|
1499
|
+
const ANSWER_BINDING = "answer";
|
|
1500
|
+
|
|
1501
|
+
export function answerOf(values: readonly BoundValue[] | null | undefined): string | null {
|
|
1502
|
+
if (!values) return null;
|
|
1503
|
+
for (const bound of values) {
|
|
1504
|
+
if (bound.binding === ANSWER_BINDING) return typeof bound.value === "string" ? bound.value : null;
|
|
1505
|
+
}
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
export function scoreView(score: number, answered: number): UiNode {
|
|
1510
|
+
return {
|
|
1511
|
+
type: "Card",
|
|
1512
|
+
props: { title: "__TITLE__" },
|
|
1513
|
+
children: [
|
|
1514
|
+
{ type: "Stat", props: { label: "Score", value: score + " of " + QUESTIONS.length } },
|
|
1515
|
+
{ type: "Progress", props: { value: answered / QUESTIONS.length } },
|
|
1516
|
+
],
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
export function questionView(index: number, score: number): UiNode {
|
|
1521
|
+
const q = QUESTIONS[index];
|
|
1522
|
+
return {
|
|
1523
|
+
type: "Stack",
|
|
1524
|
+
children: [
|
|
1525
|
+
scoreView(score, index),
|
|
1526
|
+
{
|
|
1527
|
+
type: "Form",
|
|
1528
|
+
key: QUESTION_FORM,
|
|
1529
|
+
props: {
|
|
1530
|
+
title: "Question " + (index + 1) + " of " + QUESTIONS.length,
|
|
1531
|
+
submitLabel: "Answer",
|
|
1532
|
+
submit: "action:" + ANSWER_ACTION,
|
|
1533
|
+
},
|
|
1534
|
+
children: [
|
|
1535
|
+
{ type: "TextContent", props: { text: q.prompt, size: "title" } },
|
|
1536
|
+
{ type: "Choice", props: { label: "Your answer", value: "$" + ANSWER_BINDING, options: q.options } },
|
|
1537
|
+
],
|
|
1538
|
+
},
|
|
1539
|
+
],
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1544
|
+
let index = 0;
|
|
1545
|
+
let score = 0;
|
|
1546
|
+
ctx.ui.render(questionView(index, score));
|
|
1547
|
+
const stop = ctx.ui.on_event((event) => {
|
|
1548
|
+
if (event.action !== ANSWER_ACTION || event.trigger !== "submit") return;
|
|
1549
|
+
const q = QUESTIONS[index];
|
|
1550
|
+
if (answerOf(event.values) === q.options[q.answer]) score++;
|
|
1551
|
+
index++;
|
|
1552
|
+
if (index < QUESTIONS.length) {
|
|
1553
|
+
ctx.ui.render(questionView(index, score));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
stop();
|
|
1557
|
+
ctx.ui.render(scoreView(score, QUESTIONS.length));
|
|
1558
|
+
ctx.ui.toast(score === QUESTIONS.length ? "Perfect score" : "Thanks for playing");
|
|
1559
|
+
});
|
|
1560
|
+
ctx.on_dispose(stop);
|
|
1561
|
+
}
|
|
1562
|
+
`;
|
|
1563
|
+
var QUIZ_TEST = `import { createSimulatorCtx, type UiNode } from "@cimplify/sdk/interactives";
|
|
1564
|
+
import { describe, expect, it } from "vitest";
|
|
1565
|
+
import main, { ANSWER_ACTION, QUESTION_FORM, QUESTIONS } from "../src/client";
|
|
1566
|
+
|
|
1567
|
+
describe("quiz", () => {
|
|
1568
|
+
it("scores a perfect run", async () => {
|
|
1569
|
+
const trees: UiNode[] = [];
|
|
1570
|
+
const sim = createSimulatorCtx({
|
|
1571
|
+
profile: "native",
|
|
1572
|
+
host: { render: (tree) => trees.push(tree), toast: () => {} },
|
|
1573
|
+
});
|
|
1574
|
+
await main(sim.ctx);
|
|
1575
|
+
for (const q of QUESTIONS) {
|
|
1576
|
+
sim.dispatch({
|
|
1577
|
+
kind: "action",
|
|
1578
|
+
action: ANSWER_ACTION,
|
|
1579
|
+
element: QUESTION_FORM,
|
|
1580
|
+
trigger: "submit",
|
|
1581
|
+
values: [{ binding: "answer", value: q.options[q.answer] }],
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
expect(JSON.stringify(trees[trees.length - 1])).toContain(QUESTIONS.length + " of " + QUESTIONS.length);
|
|
1585
|
+
});
|
|
1586
|
+
});
|
|
1587
|
+
`;
|
|
1588
|
+
var ORDERING_CLIENT = `import type { BoundValue, Ctx, UiNode } from "@cimplify/sdk/interactives";
|
|
1589
|
+
|
|
1590
|
+
export interface MenuItem {
|
|
1591
|
+
readonly id: string;
|
|
1592
|
+
readonly name: string;
|
|
1593
|
+
readonly priceMinor: number;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
export const MENU: readonly MenuItem[] = [
|
|
1597
|
+
{ id: "jollof", name: "Jollof rice", priceMinor: 4500 },
|
|
1598
|
+
{ id: "waakye", name: "Waakye", priceMinor: 3500 },
|
|
1599
|
+
{ id: "kelewele", name: "Kelewele", priceMinor: 1500 },
|
|
1600
|
+
];
|
|
1601
|
+
|
|
1602
|
+
export const ORDER_ACTION = "order";
|
|
1603
|
+
const MENU_FORM = "menu";
|
|
1604
|
+
const QUANTITY_PREFIX = "qty_";
|
|
1605
|
+
const MINOR_UNITS = 100;
|
|
1606
|
+
const MAX_QUANTITY = 9;
|
|
1607
|
+
|
|
1608
|
+
function menuIndex(binding: string): number {
|
|
1609
|
+
if (!binding.startsWith(QUANTITY_PREFIX)) return -1;
|
|
1610
|
+
for (let i = 0; i < MENU.length; i++) {
|
|
1611
|
+
if (binding.length === QUANTITY_PREFIX.length + MENU[i].id.length && binding.endsWith(MENU[i].id)) return i;
|
|
1612
|
+
}
|
|
1613
|
+
return -1;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
export function quantities(values: readonly BoundValue[] | null | undefined): Int32Array {
|
|
1617
|
+
const out = new Int32Array(MENU.length);
|
|
1618
|
+
if (!values) return out;
|
|
1619
|
+
for (const bound of values) {
|
|
1620
|
+
const index = menuIndex(bound.binding);
|
|
1621
|
+
const q = bound.value;
|
|
1622
|
+
if (index >= 0 && typeof q === "number" && Number.isInteger(q) && q > 0) out[index] = Math.min(q, MAX_QUANTITY);
|
|
1623
|
+
}
|
|
1624
|
+
return out;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
export function totalMinor(counts: Int32Array): number {
|
|
1628
|
+
let total = 0;
|
|
1629
|
+
for (let i = 0; i < MENU.length; i++) total += counts[i] * MENU[i].priceMinor;
|
|
1630
|
+
return total;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function menuForm(): UiNode {
|
|
1634
|
+
return {
|
|
1635
|
+
type: "Form",
|
|
1636
|
+
key: MENU_FORM,
|
|
1637
|
+
props: {
|
|
1638
|
+
title: "__TITLE__",
|
|
1639
|
+
subtitle: "Choose what you would like",
|
|
1640
|
+
submitLabel: "Review order",
|
|
1641
|
+
submit: "action:" + ORDER_ACTION,
|
|
1642
|
+
},
|
|
1643
|
+
children: MENU.map((item) => ({
|
|
1644
|
+
type: "Stepper",
|
|
1645
|
+
props: { label: item.name, value: "$" + QUANTITY_PREFIX + item.id, min: 0, max: MAX_QUANTITY },
|
|
1646
|
+
})),
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function summary(ctx: Ctx, counts: Int32Array): UiNode {
|
|
1651
|
+
const rows: UiNode[] = [];
|
|
1652
|
+
for (let i = 0; i < MENU.length; i++) {
|
|
1653
|
+
if (counts[i] === 0) continue;
|
|
1654
|
+
rows.push({ type: "KeyValue", props: { label: counts[i] + " x " + MENU[i].name, value: ctx.locale.format_money((counts[i] * MENU[i].priceMinor / MINOR_UNITS).toFixed(2)) } });
|
|
1655
|
+
}
|
|
1656
|
+
rows.push({ type: "Divider" });
|
|
1657
|
+
rows.push({ type: "KeyValue", props: { label: "Total", value: ctx.locale.format_money((totalMinor(counts) / MINOR_UNITS).toFixed(2)) } });
|
|
1658
|
+
return { type: "Card", props: { title: "Your order" }, children: rows };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1662
|
+
ctx.ui.render(menuForm());
|
|
1663
|
+
const stop = ctx.ui.on_event((event) => {
|
|
1664
|
+
if (event.action !== ORDER_ACTION || event.trigger !== "submit") return;
|
|
1665
|
+
stop();
|
|
1666
|
+
ctx.ui.render(summary(ctx, quantities(event.values)));
|
|
1667
|
+
});
|
|
1668
|
+
ctx.on_dispose(stop);
|
|
1669
|
+
}
|
|
1670
|
+
`;
|
|
1671
|
+
var ORDERING_TEST = `import { describe, expect, it } from "vitest";
|
|
1672
|
+
import { quantities, totalMinor } from "../src/client";
|
|
1673
|
+
|
|
1674
|
+
describe("ordering", () => {
|
|
1675
|
+
it("totals the chosen quantities in minor units", () => {
|
|
1676
|
+
const counts = quantities([
|
|
1677
|
+
{ binding: "qty_jollof", value: 2 },
|
|
1678
|
+
{ binding: "qty_kelewele", value: 1 },
|
|
1679
|
+
{ binding: "qty_waakye", value: -3 },
|
|
1680
|
+
{ binding: "note", value: 4 },
|
|
1681
|
+
]);
|
|
1682
|
+
expect(Array.from(counts)).toEqual([2, 0, 1]);
|
|
1683
|
+
expect(totalMinor(counts)).toBe(10500);
|
|
1684
|
+
});
|
|
1685
|
+
});
|
|
1686
|
+
`;
|
|
1687
|
+
var BOOKING_CLIENT = `import type { BoundValue, Ctx, UiNode } from "@cimplify/sdk/interactives";
|
|
1688
|
+
|
|
1689
|
+
export const SLOTS: readonly { readonly value: string; readonly label: string; readonly available: boolean }[] = [
|
|
1690
|
+
{ value: "09:00", label: "9:00 am", available: true },
|
|
1691
|
+
{ value: "11:00", label: "11:00 am", available: true },
|
|
1692
|
+
{ value: "14:00", label: "2:00 pm", available: false },
|
|
1693
|
+
{ value: "16:00", label: "4:00 pm", available: true },
|
|
1694
|
+
];
|
|
1695
|
+
|
|
1696
|
+
export interface Booking {
|
|
1697
|
+
readonly slot: string;
|
|
1698
|
+
readonly party: number;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export const BOOK_ACTION = "book";
|
|
1702
|
+
const BOOKING_FORM = "booking";
|
|
1703
|
+
|
|
1704
|
+
export function fieldsOf(values: readonly BoundValue[] | null | undefined): Readonly<Record<string, unknown>> {
|
|
1705
|
+
const fields: Record<string, unknown> = {};
|
|
1706
|
+
if (values) for (const bound of values) fields[bound.binding] = bound.value;
|
|
1707
|
+
return fields;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
export function bookingOf(value: unknown): Booking | null {
|
|
1711
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1712
|
+
const record = value as Readonly<Record<string, unknown>>;
|
|
1713
|
+
const slot = record.slot;
|
|
1714
|
+
const party = record.party;
|
|
1715
|
+
if (typeof slot !== "string" || typeof party !== "number") return null;
|
|
1716
|
+
if (!SLOTS.some((s) => s.value === slot && s.available)) return null;
|
|
1717
|
+
return { slot, party };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function bookingForm(): UiNode {
|
|
1721
|
+
return {
|
|
1722
|
+
type: "Form",
|
|
1723
|
+
key: BOOKING_FORM,
|
|
1724
|
+
props: { title: "__TITLE__", subtitle: "Book a table", submitLabel: "Book", submit: "action:" + BOOK_ACTION },
|
|
1725
|
+
children: [
|
|
1726
|
+
{ type: "Select", props: { label: "Time", value: "$slot", options: SLOTS, title: "Pick a time" } },
|
|
1727
|
+
{ type: "Stepper", props: { label: "Guests", value: "$party", min: 1, max: 8 } },
|
|
1728
|
+
],
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function confirmation(booking: Booking): UiNode {
|
|
1733
|
+
return {
|
|
1734
|
+
type: "Card",
|
|
1735
|
+
props: { title: "Booked" },
|
|
1736
|
+
children: [
|
|
1737
|
+
{ type: "KeyValue", props: { label: "Time", value: booking.slot } },
|
|
1738
|
+
{ type: "KeyValue", props: { label: "Guests", value: String(booking.party) } },
|
|
1739
|
+
],
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
export default async function main(ctx: Ctx): Promise<void> {
|
|
1744
|
+
const store = await ctx.open_store({ name: "bookings", scope: "personal", classification: "personal", schema: { name: "booking", version: 1 } });
|
|
1745
|
+
const saved = bookingOf((await store.get({ key: "latest" })).value);
|
|
1746
|
+
if (saved) {
|
|
1747
|
+
ctx.ui.render(confirmation(saved));
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
ctx.ui.render(bookingForm());
|
|
1751
|
+
let saving = false;
|
|
1752
|
+
const stop = ctx.ui.on_event((event) => {
|
|
1753
|
+
if (saving || event.action !== BOOK_ACTION || event.trigger !== "submit") return;
|
|
1754
|
+
const booking = bookingOf(fieldsOf(event.values));
|
|
1755
|
+
if (!booking) {
|
|
1756
|
+
ctx.ui.toast("Choose an available time");
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
saving = true;
|
|
1760
|
+
store.commit({ writes: [{ key: "latest", op: "put", value: booking }] }).then(
|
|
1761
|
+
() => {
|
|
1762
|
+
stop();
|
|
1763
|
+
ctx.ui.render(confirmation(booking));
|
|
1764
|
+
},
|
|
1765
|
+
() => {
|
|
1766
|
+
saving = false;
|
|
1767
|
+
ctx.ui.toast("Your booking was not saved, try again");
|
|
1768
|
+
},
|
|
1769
|
+
);
|
|
1770
|
+
});
|
|
1771
|
+
ctx.on_dispose(stop);
|
|
1772
|
+
}
|
|
1773
|
+
`;
|
|
1774
|
+
var BOOKING_TEST = `import { describe, expect, it } from "vitest";
|
|
1775
|
+
import { bookingOf, fieldsOf } from "../src/client";
|
|
1776
|
+
|
|
1777
|
+
describe("booking", () => {
|
|
1778
|
+
it("accepts an available slot and refuses a full one", () => {
|
|
1779
|
+
const party = { binding: "party", value: 2 };
|
|
1780
|
+
expect(bookingOf(fieldsOf([{ binding: "slot", value: "09:00" }, party]))).toEqual({ slot: "09:00", party: 2 });
|
|
1781
|
+
expect(bookingOf(fieldsOf([{ binding: "slot", value: "14:00" }, party]))).toBeNull();
|
|
1782
|
+
expect(bookingOf(fieldsOf(null))).toBeNull();
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1785
|
+
`;
|
|
1786
|
+
var NATIVE_TEMPLATES = {
|
|
1787
|
+
blank: {
|
|
1788
|
+
description: "A blank Interactive",
|
|
1789
|
+
category: "everyday",
|
|
1790
|
+
profile: "native",
|
|
1791
|
+
game: false,
|
|
1792
|
+
capabilities: { identity: "Greets you by your display name" },
|
|
1793
|
+
files: [
|
|
1794
|
+
{ path: "src/client.ts", content: BLANK_CLIENT },
|
|
1795
|
+
{ path: "tests/client.test.ts", content: BLANK_TEST }
|
|
1796
|
+
]
|
|
1797
|
+
},
|
|
1798
|
+
quiz: {
|
|
1799
|
+
description: "A three question quiz",
|
|
1800
|
+
category: "games",
|
|
1801
|
+
profile: "native",
|
|
1802
|
+
game: false,
|
|
1803
|
+
capabilities: {},
|
|
1804
|
+
files: [
|
|
1805
|
+
{ path: "src/client.ts", content: QUIZ_CLIENT },
|
|
1806
|
+
{ path: "tests/client.test.ts", content: QUIZ_TEST }
|
|
1807
|
+
]
|
|
1808
|
+
},
|
|
1809
|
+
ordering: {
|
|
1810
|
+
description: "Pick dishes from a menu and see your total",
|
|
1811
|
+
category: "everyday",
|
|
1812
|
+
profile: "native",
|
|
1813
|
+
game: false,
|
|
1814
|
+
capabilities: {},
|
|
1815
|
+
files: [
|
|
1816
|
+
{ path: "src/client.ts", content: ORDERING_CLIENT },
|
|
1817
|
+
{ path: "tests/client.test.ts", content: ORDERING_TEST }
|
|
1818
|
+
]
|
|
1819
|
+
},
|
|
1820
|
+
booking: {
|
|
1821
|
+
description: "Book a table at a time that suits you",
|
|
1822
|
+
category: "everyday",
|
|
1823
|
+
profile: "native",
|
|
1824
|
+
game: false,
|
|
1825
|
+
capabilities: { store: "Remembers your booking" },
|
|
1826
|
+
files: [
|
|
1827
|
+
{ path: "src/client.ts", content: BOOKING_CLIENT },
|
|
1828
|
+
{ path: "tests/client.test.ts", content: BOOKING_TEST }
|
|
1829
|
+
]
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// src/interactives/templates/index.ts
|
|
1834
|
+
var TEMPLATE_NAMES = [
|
|
1835
|
+
"blank",
|
|
1836
|
+
"quiz",
|
|
1837
|
+
"ordering",
|
|
1838
|
+
"booking",
|
|
1839
|
+
"racing",
|
|
1840
|
+
"sport",
|
|
1841
|
+
"board",
|
|
1842
|
+
"tycoon",
|
|
1843
|
+
"shootout",
|
|
1844
|
+
"runner",
|
|
1845
|
+
"five-a-side",
|
|
1846
|
+
"ludo",
|
|
1847
|
+
"word",
|
|
1848
|
+
"danfo",
|
|
1849
|
+
"courier",
|
|
1850
|
+
"frontier",
|
|
1851
|
+
"genesis",
|
|
1852
|
+
"chemistry",
|
|
1853
|
+
...ENGINE_TEMPLATE_NAMES
|
|
1854
|
+
];
|
|
1855
|
+
var TEMPLATES = {
|
|
1856
|
+
blank: NATIVE_TEMPLATES.blank,
|
|
1857
|
+
quiz: NATIVE_TEMPLATES.quiz,
|
|
1858
|
+
ordering: NATIVE_TEMPLATES.ordering,
|
|
1859
|
+
booking: NATIVE_TEMPLATES.booking,
|
|
1860
|
+
racing: GAME_TEMPLATES.racing,
|
|
1861
|
+
sport: GAME_TEMPLATES.sport,
|
|
1862
|
+
board: GAME_TEMPLATES.board,
|
|
1863
|
+
tycoon: KIT_GAME_TEMPLATES.tycoon,
|
|
1864
|
+
shootout: KIT_GAME_TEMPLATES.shootout,
|
|
1865
|
+
runner: KIT_GAME_TEMPLATES.runner,
|
|
1866
|
+
"five-a-side": KIT_GAME_TEMPLATES["five-a-side"],
|
|
1867
|
+
ludo: KIT_GAME_TEMPLATES.ludo,
|
|
1868
|
+
word: KIT_GAME_TEMPLATES.word,
|
|
1869
|
+
danfo: KIT_GAME_TEMPLATES.danfo,
|
|
1870
|
+
courier: KIT_GAME_TEMPLATES.courier,
|
|
1871
|
+
frontier: FRONTIER_TEMPLATE,
|
|
1872
|
+
genesis: GENESIS_TEMPLATE,
|
|
1873
|
+
chemistry: CHEMISTRY_TEMPLATE
|
|
1874
|
+
};
|
|
1875
|
+
function isTemplateName(value) {
|
|
1876
|
+
return TEMPLATE_NAMES.indexOf(value) >= 0;
|
|
1877
|
+
}
|
|
1878
|
+
function suggestedContent(template) {
|
|
1879
|
+
if (isEngineTemplate(template)) return [];
|
|
1880
|
+
return TEMPLATES[template].suggestedContent ?? [];
|
|
1881
|
+
}
|
|
1882
|
+
function renderTemplate(template, projectName) {
|
|
1883
|
+
if (isEngineTemplate(template)) return renderEngineTemplate(template, projectName);
|
|
1884
|
+
const spec = TEMPLATES[template];
|
|
1885
|
+
return [...materialize(projectName, spec), { path: AGENTS_FILE, content: agentsGuide(titleFromName(projectName), spec.profile) }];
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// src/interactives/commands/create.ts
|
|
1889
|
+
var PROJECT_NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
1890
|
+
var DEFAULT_TEMPLATE = "blank";
|
|
1891
|
+
async function createProject(parent, name, template) {
|
|
1892
|
+
if (!PROJECT_NAME.test(name)) {
|
|
1893
|
+
throw new CliError(CLI_ERROR_CODE.INVALID_INPUT, `'${name}' is not a valid project name; use lowercase letters, digits and dashes`);
|
|
1894
|
+
}
|
|
1895
|
+
const dir = path.join(parent, name);
|
|
1896
|
+
let existing = [];
|
|
1897
|
+
try {
|
|
1898
|
+
existing = await promises.readdir(dir);
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
if (err.code !== "ENOENT") throw err;
|
|
1901
|
+
}
|
|
1902
|
+
if (existing.length > 0) throw new CliError(CLI_ERROR_CODE.INVALID_INPUT, `${dir} already exists and is not empty`);
|
|
1903
|
+
const files = renderTemplate(template, name);
|
|
1904
|
+
for (const file of files) {
|
|
1905
|
+
const target = path.join(dir, ...file.path.split("/"));
|
|
1906
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
1907
|
+
await promises.writeFile(target, file.content);
|
|
1908
|
+
}
|
|
1909
|
+
return { dir, template, files: files.map((f) => f.path), suggestedContent: suggestedContent(template) };
|
|
1910
|
+
}
|
|
1911
|
+
function parseTemplate(value) {
|
|
1912
|
+
if (value === void 0) return DEFAULT_TEMPLATE;
|
|
1913
|
+
if (!isTemplateName(value)) {
|
|
1914
|
+
throw new CliError(CLI_ERROR_CODE.INVALID_INPUT, `unknown template '${value}'; choose one of ${TEMPLATE_NAMES.join(", ")}`);
|
|
1915
|
+
}
|
|
1916
|
+
return value;
|
|
1917
|
+
}
|
|
1918
|
+
async function run(argv) {
|
|
1919
|
+
const args = parseArgs(argv);
|
|
1920
|
+
const name = args.positional[0];
|
|
1921
|
+
if (!name) {
|
|
1922
|
+
throw new CliError(CLI_ERROR_CODE.INVALID_INPUT, `usage: cimplify create <name> --template <${TEMPLATE_NAMES.join("|")}>`);
|
|
1923
|
+
}
|
|
1924
|
+
const created = await createProject(process.cwd(), name, parseTemplate(flagString(args, "template")));
|
|
1925
|
+
success(`Created ${name} from the ${created.template} template`);
|
|
1926
|
+
if (!flagBool(args, "no-install")) {
|
|
1927
|
+
const install = spawnSync("bun", ["install"], { cwd: created.dir, stdio: "inherit" });
|
|
1928
|
+
if (install.status !== 0) warn("bun install failed; run it in the project folder");
|
|
1929
|
+
}
|
|
1930
|
+
info("");
|
|
1931
|
+
info(` cd ${name}`);
|
|
1932
|
+
if (created.suggestedContent.length > 0) {
|
|
1933
|
+
info(` cimplify add ${created.suggestedContent.join(" ")} ${dim("use the Workshop art; the game runs on primitives until you do")}`);
|
|
1934
|
+
}
|
|
1935
|
+
info(` cimplify dev ${dim("run it in the local simulator")}`);
|
|
1936
|
+
info(` cimplify test ${dim("run tests and the publication gates")}`);
|
|
1937
|
+
info(` cimplify publish ${dim("upload the source for a server build")}`);
|
|
1938
|
+
result(created);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export { TEMPLATE_NAMES, agentsGuide, createProject, parseTemplate, run };
|