dewctl 0.5.4 → 0.6.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.
@@ -0,0 +1,132 @@
1
+ # Working on this game
2
+
3
+ This is a three.js game for Dewdrop. Dewdrop draws each frame from the scene, in the style the game describes.
4
+ The docs are at https://alpha.dewdrop.world/docs, and each rule below links the page with the details.
5
+
6
+ ## 1. Agree the look with the user first
7
+
8
+ Before you build or restyle anything, ask the user for a specific look:
9
+ - the style or medium, such as clay, paper, ink sketch, watercolor or voxel;
10
+ - the setting and the time of day;
11
+ - the palette and the mood;
12
+ - the camera: first or third person, and how close.
13
+
14
+ If the answer is vague ("make it look nice"), offer three or four of these looks from the
15
+ [style recipes](https://alpha.dewdrop.world/docs/recipes), the nearest to what the user wants, and let them choose:
16
+ - **Clay:** matte plasticine with thumbprints, chunky rounded shapes, saturated colors, a tabletop set.
17
+ - **Knitted wool:** chunky knitted yarn and soft felt, visible stitches, plump rounded forms, a cozy craft diorama.
18
+ - **Paper:** folded and cut colored paper and card, sharp creases, white cut edges.
19
+ - **Voxel:** everything built from small cubes, in flat saturated colors.
20
+ - **Cartoon / anime:** cel-shaded with clean dark outlines, a bright summer afternoon.
21
+ - **Ink sketch:** thin black ink outlines on white paper, no shading, no color.
22
+ - **Watercolor:** soft washes fading into bare white paper, no outlines.
23
+ - **Picture book:** crisp ink outlines and loose watercolor washes on warm cream paper.
24
+ - **Colored pencil:** pencil hatching on textured cream paper, warm earthy colors with saturated accents.
25
+ - **Stained glass:** jewel-colored glass panes in dark lead, glowing like a lit window at night.
26
+ - **Neon cyberpunk:** a city at night in the rain, magenta and cyan tubes, wet black ground.
27
+ - **Noir:** black and white, hard light, razor-sharp black shadows, a wet street at night.
28
+ - **Horror:** a candlelit house at night, pitch-black beyond the candlelight, cold blue moonlight.
29
+ - **Winter:** deep midwinter, thick fresh snow, bare trees, warm lamplight in the windows.
30
+ - **Golden hour:** gouache countryside under a low warm sun, gold highlights, cool blue-green shade.
31
+ - **Underwater:** murky teal water, god rays from the surface, caustic light on the sea floor.
32
+
33
+ So you never stall:
34
+ - If `renderer.prompt` already starts with `style:`, the look is chosen. Keep it and don't ask.
35
+ - If nobody can answer, pick the nearest recipe, say which in your first message, and keep the look in one place.
36
+
37
+ Write the agreed look where the prompt lives: `src/look.js` in a starter game, `renderer.prompt` in `src/main.js`
38
+ in the Blank game. Ask again before you change the style.
39
+
40
+ ## 2. How Dewdrop makes a frame
41
+
42
+ Dewdrop renders the game from what is in the three.js scene ([renderer](https://alpha.dewdrop.world/docs/renderer)):
43
+ - `renderer.prompt`: the style of the whole game. It starts with `style:` and the style, then describes the scene:
44
+ `'style: claymation. A handmade plasticine miniature on a dark blue studio backdrop.'`
45
+ - `scene.background`, a `THREE.Color`, and `renderer.backgroundDescription`, the sky in words.
46
+ - Each mesh's shape, its base color (its material's `color`) and its `userData.description`.
47
+
48
+ Textures, vertex colors, emissive colors, lights, shadows, fog, custom shaders, points, lines and sprites don't
49
+ show on Dewdrop. The prompt is at most 4 KiB, the background description and each description at most 1 KiB. A
50
+ scene draws at most 65,536 objects: each mesh counts once, and so does each instance.
51
+ The view is 16:9 ([limits](https://alpha.dewdrop.world/docs/limits#the-look)).
52
+
53
+ ## 3. The rules that matter most
54
+
55
+ The controls, strongest first: colors, descriptions, shapes, light and shadow, then the prompt and the background.
56
+ A new look usually means changing the first three together.
57
+
58
+ - **Color carries the most.** A mesh comes out at its base color, whatever the words say. Very dark colors for
59
+ night, close to white for a drawing on paper. Set a color on every mesh: colors don't pass down the scene graph.
60
+ - **Dark means very dark.** Dewdrop lifts dark colors. For black, stay under about `#12` in each channel and say
61
+ "black". For deep darkness, use `#02` to `#06` and words such as "in deep shadow"
62
+ ([colors](https://alpha.dewdrop.world/docs/renderer#colors)).
63
+ - **Describe what the camera sees, in the style's words:** "the back of an owl's head" behind a character,
64
+ "a plasticine tree". Use one description for each kind of object.
65
+ - **Keep color words in step with the colors.** "Terracotta" on a pale roof came out red, and "pale cream wall"
66
+ came out white on a dark wall. Name the material without its lightness
67
+ ([descriptions](https://alpha.dewdrop.world/docs/renderer#descriptions)).
68
+ - A group's description reaches every mesh under it that has none of its own; `null` gives none, and a mesh's
69
+ `name` is never used ([scene graph](https://alpha.dewdrop.world/docs/descriptions)).
70
+ - **Don't name objects in the prompt.** Describe them on their meshes. The prompt can't override the colors.
71
+ - **Dewdrop keeps your shapes.** A style made of shapes needs them: cubes for voxel, folded flat faces for paper,
72
+ rounded lumpy forms for clay. A realistic look needs realistic shapes. A face needs eye and nose meshes. Build the
73
+ letters you want read ([shapes](https://alpha.dewdrop.world/docs/renderer#shapes)).
74
+ - **A light is a mesh.** Three.js lights don't show on Dewdrop. Give a lamp or candle a bright mesh described as
75
+ glowing. Night is every surface at about a fifth of its daylight color, with the lights in view. Words don't light
76
+ a surface; warmth comes from color. A shadow is a thin dark mesh on the ground
77
+ ([light and shadow](https://alpha.dewdrop.world/docs/renderer#light-and-shadow)).
78
+ - **The sky is the background.** Describe it in `renderer.backgroundDescription`, not the prompt, with a matching
79
+ `scene.background` color. A gradient and the sun come from the words; a sun disc mesh shows as a flat coin. Big
80
+ clouds are stacked white spheres about 1 km or more away. Flat bands or panels in the sky become a wall
81
+ ([background](https://alpha.dewdrop.world/docs/renderer#style-prompt-and-background)).
82
+ - Mist, rain and god rays come from the words. `scene.fog` doesn't show; to fade distance, or for pools of light
83
+ and sunlight, bake them into colors ([baked light](https://alpha.dewdrop.world/docs/baked-light)).
84
+ - **Keep the camera clear.** A mesh very close to the camera becomes a big smooth blob. Keep scenery and moving
85
+ parts a few meters from anywhere the camera goes. In first person, hold items 0.6 to 0.7 m from the camera, as
86
+ its children ([camera](https://alpha.dewdrop.world/docs/camera)).
87
+ - **Small moving things smear.** Make what matters large in frame. Fish, flocks and sparks: muted, about 0.6 m long
88
+ or more, 8 m or more from the camera. Smoke and steam can't be built. Turn flat things to face the camera
89
+ ([what to watch for](https://alpha.dewdrop.world/docs/renderer#what-to-watch-for)).
90
+ - To find which mesh became a blob, give the suspects a loud color such as magenta for a moment, and check again.
91
+
92
+ The code ([limits](https://alpha.dewdrop.world/docs/limits)):
93
+ - The frame rate on Dewdrop is often far lower than a browser's own three.js, and varies. Move by the real time
94
+ that passed, don't cap a frame's time below about 0.25 s, and step in small fixed steps.
95
+ - `index.html`'s own scripts don't run. Leave `html` and `body` without a background, and don't set
96
+ `color-scheme: dark`. Put what the page shows under `public/ui/`, or import it.
97
+ - `fetch` reads only the game's own files. WebSocket, WebRTC and workers aren't available. Sound plays through
98
+ Web Audio; `<audio>` and `<video>` make no sound.
99
+ - Keep `three` at 0.185.1, which Dewdrop provides ([project](https://alpha.dewdrop.world/docs/project)).
100
+
101
+ ## 4. The working loop
102
+
103
+ Follow the [development flow](https://alpha.dewdrop.world/docs/development-flow). The game runs on Dewdrop alone,
104
+ in its dev version; there is no local preview.
105
+ 1. **Shapes:** build the scene and the game from simple shapes, and play it until it works.
106
+ Check that the player and the roads show. Seed any random layout.
107
+ 2. **Colors:** give every mesh its base color.
108
+ 3. **Descriptions:** say what each thing is and what it's made of, in the style's words.
109
+ 4. **Style prompt:** set the prompt for the whole game, and the sky.
110
+
111
+ After each step, upload. While the user runs `npm run dev`, every save uploads by itself and the page reloads;
112
+ otherwise `npx dewctl dev` builds the game, uploads it and prints the link to play it on Dewdrop. Don't run both at
113
+ once: they build into the same folder. Judge the look there, in motion, not from a still frame. The first second is
114
+ plain and grey.
115
+
116
+ You can't see Dewdrop's frames. Ask the user specific questions ("Does the lantern glow?", "What does the sky look
117
+ like?"), or ask for a screenshot. In the dev version, `dewdrop.evaluate` runs code in the game from the browser
118
+ console or a script driving the browser, to read the game's state or change it
119
+ ([run code](https://alpha.dewdrop.world/docs/development-flow#run-code-in-your-dev-version)). Change one thing
120
+ at a time, so you know what each change did.
121
+
122
+ ## 5. The project
123
+
124
+ - `dewdrop.json` connects the folder to the game: its name, the build command, the folder the build writes, and
125
+ the thumbnail ([dewdrop.json](https://alpha.dewdrop.world/docs/dewdrop-json)).
126
+ - `npm run dev` plays the dev version on Dewdrop and uploads it again on every save; `npx dewctl dev` uploads it
127
+ once. `npx dewctl status` shows both versions. Run `npx dewctl publish` only when the user asks: it puts the game
128
+ in front of players ([commands](https://alpha.dewdrop.world/docs/cli)).
129
+ - In a starter game, README.md says how the game works and why its look works. Read it first.
130
+ - `thumbnail.jpg` is the image players see for the game. A starter's shows the starter, so once the look has
131
+ changed, ask the user for a screenshot of the game on Dewdrop to replace it before publishing (a PNG, JPEG or WebP
132
+ of at most 1 MB, at 16:9).
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "my-game",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": { "dev": "dewctl dev --watch", "build": "vite build" },
6
+ "dependencies": { "three": "0.185.1" },
7
+ "devDependencies": { "vite": "^8" }
8
+ }
@@ -1,4 +1,4 @@
1
- // Your game. Save a change while `dewctl dev --watch` runs and the game on Dewdrop updates.
1
+ // Your game. Save a change while `npm run dev` runs and the game on Dewdrop updates.
2
2
  import * as THREE from 'three';
3
3
 
4
4
  const scene=new THREE.Scene();
@@ -14,10 +14,6 @@ const cube=new THREE.Mesh(new THREE.BoxGeometry(1.5,1.5,1.5),new THREE.MeshStand
14
14
  // …and what this mesh is.
15
15
  cube.userData.description='A polished turquoise cube';
16
16
  scene.add(cube);
17
- scene.add(new THREE.AmbientLight(0xffffff,1));
18
- const light=new THREE.DirectionalLight(0xffffff,2);
19
- light.position.set(3,4,5);
20
- scene.add(light);
21
17
  const heading=document.createElement('h1');
22
18
  heading.textContent='Hello from your Dewdrop game';
23
19
  heading.style.cssText='position:absolute;top:20px;left:24px;color:white;font:24px sans-serif;';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dewctl",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Build and publish three.js games on Dewdrop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.js CHANGED
@@ -48,6 +48,7 @@ export class Client {
48
48
  game(game,signal){return this.api(`/v1/games/${game}`,{signal});}
49
49
  releases(game){return this.api(`/v1/games/${game}/releases`);}
50
50
  deleteRelease(game,release){return this.api(`/v1/games/${game}/releases/${release}`,{method:'DELETE'});}
51
+ deleteGame(game){return this.api(`/v1/games/${game}`,{method:'DELETE'});}
51
52
  // Makes the game this developer's if it is new, and confirms it is theirs if not: asked before anything is built, so
52
53
  // a name someone else has, or a sign-in that has ended, is known at once.
53
54
  async claim(game,signal) {
package/src/command.js CHANGED
@@ -1,2 +1,3 @@
1
- // How the developer runs this CLI, for the commands its messages suggest: through npx when npx started it, else by name.
2
- export const RUN=process.env.npm_command==='exec'?'npx dewctl':'dewctl';
1
+ // How the developer runs this CLI, for the commands its messages suggest: through npx when npx or an npm script
2
+ // (`npm run dev`, which runs `dewctl dev --watch`) started it, else by name.
3
+ export const RUN=['exec','run','run-script'].includes(process.env.npm_command)?'npx dewctl':'dewctl';
package/src/main.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Dewdrop's developer commands: sign in, connect a directory to a game, then run, publish, share and inspect it.
3
3
  import {spawn} from 'node:child_process';
4
4
  import {readFileSync} from 'node:fs';
5
- import {readProject,platformOrigin,initProject,DEWDROP} from './project.js';
5
+ import {readProject,platformOrigin,initProject,chooseTemplate,DEWDROP} from './project.js';
6
6
  import {Credentials} from './credentials.js';
7
7
  import {Client} from './client.js';
8
8
  import {deploy,watch,size} from './watch.js';
@@ -13,8 +13,11 @@ import {syncThumbnail} from './thumbnail.js';
13
13
 
14
14
  const HELP=`${RUN} — make three.js games on Dewdrop
15
15
 
16
- init [--game NAME] Connect this folder to a game; an empty folder gets the starter game.
17
- The game is named after the folder unless --game names it
16
+ init [--game NAME] [--template NAME] Connect this folder to a game. An empty folder gets a new game from
17
+ a template: blank, golden-glide, ember-wyrm, woolly-farm,
18
+ turtle-tide or hollow-manor. Without --template, init asks which at a terminal
19
+ (Enter for Blank), and starts Blank in a script. The game is named
20
+ after the folder unless --game names it
18
21
  login Sign in: prints a link to open in your browser and approve
19
22
  logout Sign this computer out
20
23
  dev [--watch] [--release ID] Build, upload and play the dev version; --watch repeats this on every
@@ -26,18 +29,22 @@ const HELP=`${RUN} — make three.js games on Dewdrop
26
29
  share EMAIL Let another developer play and update the dev version
27
30
  unshare EMAIL Take that back
28
31
  status Show the dev and published versions and where to play them
29
- releases List the uploads Dewdrop keeps: every published version, the dev version
30
- and each member's latest upload; any id, or its first 8 characters,
31
- feeds --release and delete
32
- delete ID Delete a published version nothing runs, freeing its space (owner only)
32
+ releases List the uploads Dewdrop keeps: every published version, the dev version,
33
+ each member's latest upload, and every other dev build for two weeks;
34
+ any id, or its first 8 characters, feeds --release and delete
35
+ delete ID Delete an upload nothing runs, freeing its space (owner only)
36
+ delete-game NAME Delete this game, its name typed again to confirm: both versions stop
37
+ now, and it is removed with its uploads and its players' saves. This
38
+ can't be undone (owner only)
33
39
  --version Show this CLI's version
34
40
 
35
41
  Run the commands in your game's folder: they read the game from its dewdrop.json.
36
42
  Docs: ${DEWDROP}/docs
37
43
  `;
38
- // Each command's flags; share and unshare also take one address, and delete one release. Anything else on the line is refused.
39
- const COMMANDS={init:['--game'],login:[],logout:[],dev:['--watch','--release'],stop:[],publish:['--release'],unpublish:[],share:[],unshare:[],status:[],releases:[],delete:[]};
40
- const VALUED=['--game','--release'];
44
+ // Each command's flags; share and unshare also take one address, delete one release and delete-game the game's name.
45
+ // Anything else on the line is refused.
46
+ const COMMANDS={init:['--game','--template'],login:[],logout:[],dev:['--watch','--release'],stop:[],publish:['--release'],unpublish:[],share:[],unshare:[],status:[],releases:[],delete:[],'delete-game':[]};
47
+ const VALUED=['--game','--template','--release'];
41
48
  function options(command,args) {
42
49
  const out={};
43
50
  for(let i=0;i<args.length;i++) {
@@ -48,6 +55,7 @@ function options(command,args) {
48
55
  else out[key.slice(2)]=args[++i];
49
56
  } else if(['share','unshare'].includes(command)&&!key.startsWith('-')&&out.email===undefined)out.email=key;
50
57
  else if(command==='delete'&&!key.startsWith('-')&&out.release===undefined)out.release=key;
58
+ else if(command==='delete-game'&&!key.startsWith('-')&&out.name===undefined)out.name=key;
51
59
  else throw new Error(`${command} doesn't take ${key}. Run ${RUN} --help to see what it takes.`);
52
60
  }
53
61
  if(out.release!==undefined&&!/^[a-f0-9]{8,64}$/.test(out.release))throw new Error(`${command==='delete'?'delete':'--release'} takes a release id, or its first 8 or more characters, from ${RUN} releases.`);
@@ -87,23 +95,25 @@ async function main() {
87
95
  if(!Object.hasOwn(COMMANDS,command))throw new Error(`There's no ${command} command. Run ${RUN} --help to see them.`);
88
96
  const flags=options(command,args);
89
97
  if(command==='init') {
90
- const config=initProject(process.cwd(),flags.game);
98
+ const config=await initProject(process.cwd(),flags.game,flags.template,process.stdin.isTTY&&process.stdout.isTTY?chooseTemplate:undefined);
91
99
  const run=RUN;
92
- if(config.starter)console.log(`Set up ${config.game} here. Next:\n npm install\n ${run} login\n ${run} dev --watch\nThe game is created on Dewdrop the first time you run dev. To try it here without Dewdrop, run npm run dev.`);
93
- else console.log(`Created dewdrop.json for ${config.game}. Next: ${run} login, then ${run} dev --watch.`);
94
- if(config.buildScript==='build')console.log('Using your build script (npm run build) to build for Dewdrop. It must build with the Dewdrop plugin (vite build --mode dew); if it doesn\'t, add a build:dew script that does and set "build" in dewdrop.json to ["npm","run","build:dew"].');
95
- if(!config.starter&&!config.buildScript)console.log('No build:dew or build script is in package.json, so dewdrop.json builds with npm run build:dew. Add that script, building with the Dewdrop plugin (vite build --mode dew), or set "build" in dewdrop.json to your own command.');
100
+ if(config.starter)console.log(`Set up ${config.game} here. Next:\n npm install\n ${run} login\n npm run dev\nnpm run dev builds the game, uploads it to Dewdrop and prints a link to play it, then uploads again on every save; the game is created on Dewdrop the first time.${config.template==='blank'?'':'\nRead README.md: how the game works and why its look works.'}\nAGENTS.md tells coding agents how to work on the game, starting with agreeing its look with you.`);
101
+ else console.log(`Created dewdrop.json for ${config.game}. Next: ${run} login, then ${run} dev --watch. To make npm run dev play it on Dewdrop, set package.json's dev script to dewctl dev --watch.`);
102
+ if(!config.starter&&config.buildScript)console.log(`Building for Dewdrop with npm run ${config.buildScript}, which must run vite build with the Dewdrop plugin in vite.config.js.`);
103
+ if(!config.starter&&!config.buildScript)console.log('No build script is in package.json, so dewdrop.json builds with npm run build. Add one that runs vite build with the Dewdrop plugin in vite.config.js, or set "build" in dewdrop.json to your own command.');
96
104
  return;
97
105
  }
98
106
  // Every command but init reads the game from the project's dewdrop.json.
99
107
  const project=readProject(process.cwd());
100
108
  const origin=platformOrigin();
101
109
  const credentials=new Credentials(origin),client=new Client(origin,credentials);
102
- // The commands that wait on Dewdrop stop waiting at Ctrl-C; the rest end the ordinary way.
110
+ // The commands that wait on Dewdrop stop waiting at Ctrl-C; the rest end the ordinary way. Every signal is taken,
111
+ // not the first alone: under `npm run dev`, npm passes the terminal's Ctrl-C on to the CLI, which has had it already,
112
+ // and a second signal must not end the CLI before it has said what it stopped.
103
113
  const controller=new AbortController(),{signal}=controller;
104
114
  if(['login','stop','unpublish','publish','dev'].includes(command)) {
105
- process.once('SIGINT',()=>controller.abort(new Error('Stopped.')));
106
- process.once('SIGTERM',()=>controller.abort(new Error('Stopped.')));
115
+ process.on('SIGINT',()=>controller.abort(new Error('Stopped.')));
116
+ process.on('SIGTERM',()=>controller.abort(new Error('Stopped.')));
107
117
  }
108
118
  // login makes a sign-in token and prints the link that approves it; the token is kept once Dewdrop says whose it is.
109
119
  if(command==='login') {
@@ -141,8 +151,8 @@ async function main() {
141
151
  if(command==='releases'){
142
152
  const [{releases},state]=await Promise.all([client.releases(game),client.game(game)]);
143
153
  if(!releases.length){console.log(`No uploads of ${game} yet. Run ${RUN} dev to make one.`);return;}
144
- // What each release is to the game, the reason Dewdrop keeps it.
145
- const status=release=>[release.sha256===state.dev.release&&'dev',release.sha256===state.live.release&&'published'].filter(Boolean).join(', ')||(release.published?'published before':'latest upload');
154
+ // What each release is to the game, the reason Dewdrop keeps it, and for an old dev build when it goes.
155
+ const status=release=>[release.sha256===state.dev.release&&'dev',release.sha256===state.live.release&&'published'].filter(Boolean).join(', ')||(release.published?'published before':release.expires?`dev build until ${new Date(release.expires).toISOString().slice(0,10)}`:'latest upload');
146
156
  console.log(columns([['RELEASE','UPLOADED (UTC)','SIZE','STATUS','BY'],...releases.map(release=>[release.sha256.slice(0,12),new Date(release.created).toISOString().slice(0,16).replace('T',' '),size(release.bytes),status(release),release.email])]));
147
157
  return;
148
158
  }
@@ -152,6 +162,11 @@ async function main() {
152
162
  await client.deleteRelease(game,release);
153
163
  console.log(`Deleted ${release.slice(0,12)} from ${game}.`);return;
154
164
  }
165
+ if(command==='delete-game'){
166
+ if(flags.name!==game)throw new Error(`delete-game takes the game's name to confirm. Run ${RUN} delete-game ${game} to delete ${game}.`);
167
+ await client.deleteGame(game);
168
+ console.log(`Deleted ${game}. Both versions are stopped, players can no longer open it, and it is removed with its uploads and its players' saves.`);return;
169
+ }
155
170
  if(['share','unshare'].includes(command)) {
156
171
  if(!flags.email)throw new Error(`${command} needs the developer's email address.`);
157
172
  await client.member(game,flags.email,command==='share');
package/src/project.js CHANGED
@@ -3,7 +3,10 @@ import {readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, openSyn
3
3
  import {resolve, relative, join, isAbsolute, basename, dirname} from 'node:path';
4
4
  import {createHash,randomBytes} from 'node:crypto';
5
5
  import {spawn} from 'node:child_process';
6
- import {packTar} from './tar.js';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {gunzipSync} from 'node:zlib';
8
+ import {createInterface} from 'node:readline';
9
+ import {packTar,readTar} from './tar.js';
7
10
  import {RUN} from './command.js';
8
11
 
9
12
  const MAX_BYTES = 1024 * 1024 * 1024;
@@ -37,7 +40,7 @@ export function readProject(root = process.cwd()) {
37
40
  if (!GAME_NAME.test(config.game ?? '')) throw new Error("Set game in dewdrop.json to the game's name (for example my-game).");
38
41
  projectPath(root,config.bundle);
39
42
  if (config.thumbnail !== undefined) projectPath(root,config.thumbnail);
40
- if (config.build !== undefined && (!Array.isArray(config.build) || !config.build.length || config.build.some(v=>typeof v !== 'string' || !v || v.includes('\0')))) throw new Error('build must be an argument array, such as ["npm","run","build:dew"].');
43
+ if (config.build !== undefined && (!Array.isArray(config.build) || !config.build.length || config.build.some(v=>typeof v !== 'string' || !v || v.includes('\0')))) throw new Error('build must be an argument array, such as ["npm","run","build"].');
41
44
  return {...config,root};
42
45
  }
43
46
 
@@ -101,7 +104,7 @@ export function fingerprint(project) {
101
104
  export function snapshot(project) {
102
105
  const directory=projectPath(project.root,project.bundle);
103
106
  if (!existsSync(directory)) throw new Error(project.build
104
- ? `The build wrote no ${project.bundle} folder. Set "bundle" in dewdrop.json to the folder your Dewdrop build (vite build --mode dew) writes.`
107
+ ? `The build wrote no ${project.bundle} folder. Set "bundle" in dewdrop.json to the folder your build (vite build) writes.`
105
108
  : `There's no ${project.bundle} folder to upload. Build the game first, or set "build" in dewdrop.json so ${RUN} dev builds it.`);
106
109
  const root=realpathSync(directory);
107
110
  if (root!==directory) throw new Error(`The ${project.bundle} folder can't be a symlink.`);
@@ -112,7 +115,7 @@ export function snapshot(project) {
112
115
  const nested=[...files.keys()].filter(path=>path.endsWith('/main.js')).sort((a,b)=>a.split('/').length-b.split('/').length)[0];
113
116
  throw new Error(nested
114
117
  ? `There's no main.js at the top of ${project.bundle}, but there is ${project.bundle}/${nested}. Set "bundle": "${project.bundle}/${dirname(nested)}" in dewdrop.json.`
115
- : `There's no main.js in ${project.bundle}. Build with the Dewdrop plugin (vite build --mode dew), and set "bundle" in dewdrop.json to the folder that build writes.`);
118
+ : `There's no main.js in ${project.bundle}. Build with the Dewdrop plugin in vite.config.js (vite build), and set "bundle" in dewdrop.json to the folder that build writes.`);
116
119
  }
117
120
  let bytes=0;
118
121
  const entries=[...files].map(([path,{file,size}])=>{
@@ -152,56 +155,101 @@ export async function build(project, signal) {
152
155
  if(signal?.aborted)cancel();
153
156
  });
154
157
  }
155
- // The one project shape: a Vite project with the SDK's plugin, which previews locally with `npm run dev` and
156
- // builds for Dewdrop with `npm run build:dew`. A directory holding none of the starter's files gets the
157
- // starter game, depending on the SDK and this CLI from npm; one holding any of them has a game of its own and gets
158
- // dewdrop.json alone, so nothing of it is touched and no starter is left half written.
158
+ // The one project shape: a Vite project with the SDK's plugin, which builds for Dewdrop with `npm run build`
159
+ // and plays there with `npm run dev`, which runs `dewctl dev --watch`. A directory holding none of the starter's
160
+ // files gets a new game, depending on the SDK and this CLI from npm; one holding any of them has a game of its own
161
+ // and gets dewdrop.json alone, so nothing of it is touched and no new game is left half written.
159
162
  const STARTER_FILES=['package.json','src','index.html','vite.config.js'];
160
163
  // The SDK a new game depends on — the range moves with the SDK's minor, since a caret on 0.x stays within one —
161
164
  // and this CLI, at its own version.
162
- const SDK_RANGE='^0.5.1';
165
+ const SDK_RANGE='^0.6.0';
163
166
  const CLI_RANGE=`^${JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version}`;
167
+ // The games a new one can start from: Blank, which ships with this CLI, and the starter games in the public
168
+ // starter-templates repository, a folder each, at the commit this release pins.
169
+ const STARTERS={repo:'DewdropLabs/starter-templates',commit:'4247a60d29f5477b94b498fcd31a4c3c0e873cc0'};
170
+ const TEMPLATES={
171
+ blank:['Blank','a cube and a style prompt, the smallest game'],
172
+ 'golden-glide':['Golden Glide','flight over golden-hour countryside, in gouache'],
173
+ 'ember-wyrm':['Ember Wyrm','a third-person dragon fight, in colored pencil'],
174
+ 'woolly-farm':['Woolly Farm','cozy farming, third person, in knitted wool'],
175
+ 'turtle-tide':['Turtle Tide','a sea turtle swimming through kelp, in stained glass'],
176
+ 'hollow-manor':['Hollow Manor','first-person horror by candlelight'],
177
+ };
178
+ const EXAMPLES=fileURLToPath(new URL('../examples/',import.meta.url)),BLANK=join(EXAMPLES,'starter');
179
+ // Asks at a terminal which game a new one starts from. Enter is Blank, and any answer but a listed number asks again.
180
+ export async function chooseTemplate(input=process.stdin, output=process.stdout) {
181
+ const ids=Object.keys(TEMPLATES),ask=`Choose 1–${ids.length} [1]: `;
182
+ output.write(`Start from:\n${ids.map((id,i)=>` ${i+1} ${TEMPLATES[id][0].padEnd(14)}${TEMPLATES[id][1]}`).join('\n')}\n${ask}`);
183
+ for await (const line of createInterface({input})) {
184
+ const answer=line.trim()||'1',id=/^\d$/.test(answer)&&ids[answer-1];
185
+ if(id)return id;
186
+ output.write(ask);
187
+ }
188
+ throw new Error('No game was chosen to start from.');
189
+ }
190
+ // A starter game's files, from the repository's tarball at the pinned commit (or the one DEWDROP_STARTERS names, for
191
+ // tests): the regular files under the game's folder, by their paths in it, leaving out names starting with a dot.
192
+ async function download(template) {
193
+ const name=TEMPLATES[template][0];
194
+ let response,files;
195
+ try { response=await fetch(process.env.DEWDROP_STARTERS??`https://codeload.github.com/${STARTERS.repo}/tar.gz/${STARTERS.commit}`,{signal:AbortSignal.timeout(120000)}); }
196
+ catch(error) { throw new Error(`Couldn't download the ${name} game (${error.cause?.message??error.message}). Nothing was written; check your connection and run init again.`); }
197
+ try {
198
+ if(!response.ok)throw new Error(`the download answered ${response.status}`);
199
+ files=readTar(gunzipSync(await response.bytes()));
200
+ } catch(error) { throw new Error(`Couldn't read the ${name} game (${error.message}). Nothing was written.`); }
201
+ files=files.filter(({path})=>path.startsWith(`${template}/`)&&!path.split('/').some(part=>part.startsWith('.'))).map(({path,data})=>({path:path.slice(template.length+1),data}));
202
+ if(!files.length)throw new Error(`The ${name} game is missing from the download. Nothing was written.`);
203
+ return files;
204
+ }
164
205
  // A game name from the project directory, or a random one when the directory name yields nothing usable.
165
206
  function gameNameFrom(root) {
166
207
  const slug=basename(resolve(root)).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[^a-z]+/,'').replace(/-+$/,'').slice(0,48).replace(/-+$/,'');
167
208
  return GAME_NAME.test(slug)?slug:`game-${randomBytes(4).toString('hex')}`;
168
209
  }
169
- // The build command for a project of its own: its build:dew or build npm script when it has one, named so init can
170
- // say which it chose, else the default with no script, so init can tell the developer to add one.
210
+ // The build command for a project of its own: its build:dew npm script when it has one — a project set up before
211
+ // dewdrop-sdk 0.6, whose SDK init leaves as it is, builds for Dewdrop through that script alone — else its build
212
+ // script, named so init can say which it chose, else the default with no script, so init can tell the developer to
213
+ // add one.
171
214
  function buildCommandFor(root) {
172
215
  try {
173
216
  const scripts=JSON.parse(readFileSync(join(root,'package.json'),'utf8')).scripts;
174
217
  for(const name of ['build:dew','build'])if(scripts&&typeof scripts[name]==='string')return {build:['npm','run',name],script:name};
175
218
  } catch {}
176
- return {build:['npm','run','build:dew'],script:null};
219
+ return {build:['npm','run','build'],script:null};
177
220
  }
178
- export function initProject(root, game) {
179
- const path=join(root,'dewdrop.json');
180
- if(existsSync(path))throw new Error('This folder already has a dewdrop.json.');
221
+ // Connects `root` to `game`. A new game starts from `template`, else from the one `choose` answers, else Blank, and
222
+ // gets its name, this CLI's ranges and the agent instructions; a project of its own gets dewdrop.json alone.
223
+ export async function initProject(root, game, template, choose) {
224
+ if(existsSync(join(root,'dewdrop.json')))throw new Error('This folder already has a dewdrop.json.');
181
225
  const starter=!STARTER_FILES.some(file=>existsSync(join(root,file)));
182
226
  game??=gameNameFrom(root);
183
227
  if(!GAME_NAME.test(game))throw new Error('A game name is 1–48 lowercase letters, digits or dashes, starting with a letter.');
184
- const {build,script}=starter?{build:['npm','run','build:dew'],script:'build:dew'}:buildCommandFor(root);
185
- const config={version:1,game,bundle:'dist',build};
228
+ if(template!==undefined&&!Object.hasOwn(TEMPLATES,template))throw new Error(`--template takes ${Object.keys(TEMPLATES).join(', ')}.`);
229
+ if(template&&!starter)throw new Error('This folder has a project of its own, so init only connects it to a game. Start from a template in an empty folder.');
230
+ if(starter)template??=choose?await choose():'blank';
231
+ const files=!starter?[]:template==='blank'?readdirSync(BLANK,{recursive:true}).filter(path=>lstatSync(join(BLANK,path)).isFile()).map(path=>({path,data:readFileSync(join(BLANK,path))})):await download(template);
232
+ const {build,script}=starter?{build:['npm','run','build'],script:'build'}:buildCommandFor(root);
233
+ const config={version:1,game,bundle:'dist',build,...files.some(({path})=>path==='thumbnail.jpg')&&{thumbnail:'thumbnail.jpg'}};
234
+ // The game's package and page take its name, and the package this CLI's ranges of the SDK and itself.
235
+ const edits=new Map([
236
+ ['package.json',text=>{const pkg=JSON.parse(text);pkg.name=game;pkg.devDependencies={...pkg.devDependencies,'dewdrop-sdk':SDK_RANGE,dewctl:CLI_RANGE};return JSON.stringify(pkg,null,2)+'\n';}],
237
+ ['index.html',text=>text.replace(/<title>[^<]*<\/title>/,`<title>${game}</title>`)],
238
+ ]);
186
239
  // Everything init writes, so a failure part-way removes it again and a retry starts from the same empty folder.
240
+ const taken=files.find(({path})=>existsSync(join(root,path)));
241
+ if(taken)throw new Error(`${taken.path} is already in this folder, and the game has one of its own. Move it out, or start in an empty folder.`);
187
242
  const made=[];
188
- const write=(file,data)=>{writeFileSync(file,data,{flag:'wx'});made.push(file);};
189
- const folder=dir=>{if(!existsSync(dir)){mkdirSync(dir,{recursive:true});made.push(dir);}};
243
+ const write=(path,data)=>{const file=join(root,path),dir=mkdirSync(dirname(file),{recursive:true});if(dir)made.push(dir);writeFileSync(file,data,{flag:'wx'});made.push(file);};
190
244
  try {
191
- if(starter) {
192
- folder(join(root,'src'));
193
- const template=name=>readFileSync(new URL(`../examples/starter/${name}`,import.meta.url),'utf8');
194
- write(join(root,'src/main.js'),template('main.js'));
195
- write(join(root,'index.html'),template('index.html').replace('<title>My game</title>',`<title>${game}</title>`));
196
- write(join(root,'vite.config.js'),template('vite.config.js'));
197
- const devDependencies={'dewdrop-sdk':SDK_RANGE,dewctl:CLI_RANGE,vite:'^8'};
198
- write(join(root,'package.json'),JSON.stringify({name:game,private:true,type:'module',scripts:{dev:'vite','build:dew':'vite build --mode dew'},dependencies:{three:'0.185.1'},devDependencies},null,2)+'\n');
199
- if(!existsSync(join(root,'.gitignore')))write(join(root,'.gitignore'),'dist/\nnode_modules/\n.env*\n');
200
- }
201
- write(path,JSON.stringify(config,null,2)+'\n');
245
+ for(const {path,data} of files)write(path,edits.get(path)?.(data.toString())??data);
246
+ // The files every new game gets beside its own, each left as it is when the folder has one already: AGENTS.md
247
+ // guides coding agents, which read it by that name.
248
+ if(starter)for(const [name,data] of Object.entries({'.gitignore':'dist/\nnode_modules/\n','AGENTS.md':readFileSync(join(EXAMPLES,'AGENTS.md'))}))if(!existsSync(join(root,name)))write(name,data);
249
+ write('dewdrop.json',JSON.stringify(config,null,2)+'\n');
202
250
  } catch(error) {
203
251
  for(const file of made.reverse())rmSync(file,{recursive:true,force:true});
204
252
  throw error;
205
253
  }
206
- return {...config,starter,buildScript:script};
254
+ return {...config,starter,template,buildScript:script};
207
255
  }
package/src/tar.js CHANGED
@@ -46,3 +46,28 @@ export function packTar(entries) {
46
46
  }
47
47
  return out;
48
48
  }
49
+
50
+ // The regular files of a ustar archive, such as a GitHub tarball, as `{path, data}` with the archive's one top-level
51
+ // folder taken off each path. Folders and pax headers are passed over, but for the path a pax header gives the file
52
+ // after it; any other kind of entry, a path that could leave the folder, or an archive cut short is refused.
53
+ export function readTar(archive) {
54
+ const files=[];
55
+ let top,long;
56
+ for(let offset=0;offset+BLOCK<=archive.length;) {
57
+ const block=archive.subarray(offset,offset+BLOCK);
58
+ if(!block.some(byte=>byte))return files;
59
+ const text=(start,length)=>{const raw=block.subarray(start,start+length),end=raw.indexOf(0);return raw.subarray(0,end<0?length:end).toString();};
60
+ const size=text(124,12).trim(),type=text(156,1)||'0',start=offset+BLOCK,end=start+parseInt(size,8);
61
+ if(text(257,5)!=='ustar'||!/^[0-7]+$/.test(size)||end>archive.length)throw new Error(end>archive.length?'the archive is cut short':'the archive is damaged');
62
+ const data=archive.subarray(start,end);
63
+ offset=start+padded(end-start);
64
+ if(type==='x'){long=/(?:^|\n)\d+ path=([^\n]*)/.exec(data.toString())?.[1];continue;}
65
+ const path=long??[text(345,155),text(0,100)].filter(Boolean).join('/'),parts=path.split('/');
66
+ long=undefined;
67
+ if(type==='5'||type==='g')continue;
68
+ if(type!=='0')throw new Error(`the archive holds ${path}, which isn't a regular file`);
69
+ if(parts.length<2||parts.some(part=>!part||part==='.'||part==='..')||parts[0]!==(top??=parts[0]))throw new Error(`the archive holds a file outside its folder: ${path}`);
70
+ files.push({path:parts.slice(1).join('/'),data});
71
+ }
72
+ throw new Error('the archive is cut short');
73
+ }