dotframe 0.1.5 → 0.1.7
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/CHANGELOG.md +22 -0
- package/cli/commands/docker.ts +179 -0
- package/cli/commands/doctor.ts +70 -7
- package/cli/commands/online.ts +93 -0
- package/cli/commands/play.ts +17 -3
- package/cli/commands/ship.ts +23 -9
- package/cli/ios.ts +271 -0
- package/cli/lib.ts +4 -2
- package/cli/main.ts +44 -11
- package/cli/native.ts +10 -5
- package/cli/relay.ts +65 -0
- package/native/df_native.c +5 -0
- package/package.json +1 -1
- package/scripts/vendor.sh +11 -5
- package/skill-data/core/SKILL.md +6 -0
- package/skill-data/export-web/SKILL.md +14 -0
- package/skill-data/ios/SKILL.md +45 -10
- package/skill-data/macos/SKILL.md +27 -0
- package/skill-data/netplay/SKILL.md +17 -0
- package/src/checksum.ts +22 -0
- package/src/detmath.ts +217 -0
- package/src/netplay.ts +232 -0
- package/src/relay-client.ts +42 -0
- package/templates/_base/dotframe.json +10 -1
- package/templates/_base/main.ios.ts +19 -0
- package/templates/_base/main.native.ts +2 -1
- package/templates/_base/main.web.ts +9 -1
- package/templates/_base/sim.ts +2 -4
- package/templates/_base/src/setup.ts +112 -11
- package/templates/_base/tsconfig.json +1 -1
- package/templates/blank/game.ts +10 -1
- package/templates/fighter/game.ts +15 -1
- package/templates/platformer/game.ts +27 -1
package/cli/ios.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { CliError, type Ctx, exec, print, type RunResult, which } from "./lib";
|
|
5
|
+
import { ENGINE, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
|
|
6
|
+
|
|
7
|
+
export interface IosTarget {
|
|
8
|
+
platform: "ios";
|
|
9
|
+
// Entry module exporting init(base: string): void and frame(time: number): boolean (see the ios skill).
|
|
10
|
+
entry: string;
|
|
11
|
+
bundleId: string;
|
|
12
|
+
team: string;
|
|
13
|
+
// PNG, any size; scaled to the 1024 px App Store icon.
|
|
14
|
+
icon?: string;
|
|
15
|
+
orientation?: "landscape" | "portrait";
|
|
16
|
+
// Folder bundled under <app>/game/<assets>; the engine fonts go under <app>/game/dotframe/assets/fonts.
|
|
17
|
+
assets?: string;
|
|
18
|
+
// Home screen name; defaults to the game name.
|
|
19
|
+
displayName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SKILL = "ios";
|
|
23
|
+
|
|
24
|
+
async function scriptcVersion(ctx: Ctx, root: string): Promise<string> {
|
|
25
|
+
const r = await exec({ ...ctx, json: true }, root, { label: "scriptc version", argv: ["scriptc", "--version"] });
|
|
26
|
+
return r.output.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// scriptc's iOS runtime pack must match the compiler. Looked up in the game, the engine, then ~/.dotframe/scriptc-ios.
|
|
30
|
+
export async function iosRuntimePack(ctx: Ctx, root: string): Promise<{ path: string | null; want: string; found: string | null }> {
|
|
31
|
+
const want = await scriptcVersion(ctx, root);
|
|
32
|
+
const dirs = [root, ENGINE, join(homedir(), ".dotframe", "scriptc-ios")];
|
|
33
|
+
for (const dir of dirs) {
|
|
34
|
+
const pack = join(dir, "node_modules", "@scriptc", "runtime-ios-arm64");
|
|
35
|
+
if (!existsSync(join(pack, "package.json"))) continue;
|
|
36
|
+
const found = (JSON.parse(await Bun.file(join(pack, "package.json")).text()) as { version: string }).version;
|
|
37
|
+
return { path: found === want ? pack : null, want, found };
|
|
38
|
+
}
|
|
39
|
+
return { path: null, want, found: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function iosPackFix(want: string): string {
|
|
43
|
+
// Fresh scriptc releases trip bun's minimum release age for a couple of days.
|
|
44
|
+
return `mkdir -p ~/.dotframe/scriptc-ios && cd ~/.dotframe/scriptc-ios && bun add @scriptc/runtime-ios-arm64@${want} --minimum-release-age 0`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function identifier(name: string): string {
|
|
48
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "") || "game";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scheme(name: string): string {
|
|
52
|
+
return name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("") || "Game";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hostC(id: string, display: string): string {
|
|
56
|
+
return `// iOS host for ${display}, generated by dotframe build ios. SDL3 owns the app lifecycle through main callbacks
|
|
57
|
+
// and drives the scriptc library: init once with the bundle's game folder, then frame per display refresh.
|
|
58
|
+
#define SDL_MAIN_USE_CALLBACKS 1
|
|
59
|
+
#include <SDL3/SDL.h>
|
|
60
|
+
#include <SDL3/SDL_main.h>
|
|
61
|
+
#include <stdint.h>
|
|
62
|
+
#include <string.h>
|
|
63
|
+
|
|
64
|
+
void ${id}_runtime_init(void);
|
|
65
|
+
int32_t ${id}_register_callbacks(void);
|
|
66
|
+
void ${id}_init(const uint8_t *base, int64_t base_len);
|
|
67
|
+
uint8_t ${id}_frame(double time);
|
|
68
|
+
void df_handle_event(const SDL_Event *event);
|
|
69
|
+
void df_close(void);
|
|
70
|
+
|
|
71
|
+
static Uint64 g_start;
|
|
72
|
+
|
|
73
|
+
SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) {
|
|
74
|
+
(void)appstate;
|
|
75
|
+
(void)argc;
|
|
76
|
+
(void)argv;
|
|
77
|
+
${id}_runtime_init();
|
|
78
|
+
int32_t failed = ${id}_register_callbacks();
|
|
79
|
+
if (failed != 0) {
|
|
80
|
+
SDL_Log("${id}: %d callbacks failed to register", failed);
|
|
81
|
+
return SDL_APP_FAILURE;
|
|
82
|
+
}
|
|
83
|
+
const char *bundle = SDL_GetBasePath();
|
|
84
|
+
if (!bundle) return SDL_APP_FAILURE;
|
|
85
|
+
char base[4096];
|
|
86
|
+
SDL_snprintf(base, sizeof base, "%sgame", bundle);
|
|
87
|
+
${id}_init((const uint8_t *)base, (int64_t)strlen(base));
|
|
88
|
+
g_start = SDL_GetTicksNS();
|
|
89
|
+
return SDL_APP_CONTINUE;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
SDL_AppResult SDL_AppIterate(void *appstate) {
|
|
93
|
+
(void)appstate;
|
|
94
|
+
double time = (double)(SDL_GetTicksNS() - g_start) / 1e9;
|
|
95
|
+
return ${id}_frame(time) ? SDL_APP_CONTINUE : SDL_APP_SUCCESS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
|
99
|
+
(void)appstate;
|
|
100
|
+
df_handle_event(event);
|
|
101
|
+
return event->type == SDL_EVENT_QUIT ? SDL_APP_SUCCESS : SDL_APP_CONTINUE;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
void SDL_AppQuit(void *appstate, SDL_AppResult result) {
|
|
105
|
+
(void)appstate;
|
|
106
|
+
(void)result;
|
|
107
|
+
df_close();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// scriptc library mode references the promise runtime from bytes IO but ships none; library mode has no async.
|
|
111
|
+
void scr_promise_settled_ref(void) { SDL_assert_always(!"scriptc promise in library mode"); }
|
|
112
|
+
void scr_promise_settled_void(void) { SDL_assert_always(!"scriptc promise in library mode"); }
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function infoPlist(display: string, orientation: "landscape" | "portrait"): string {
|
|
117
|
+
const orientations =
|
|
118
|
+
orientation === "portrait"
|
|
119
|
+
? ["UIInterfaceOrientationPortrait"]
|
|
120
|
+
: ["UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"];
|
|
121
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
122
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
123
|
+
<plist version="1.0">
|
|
124
|
+
<dict>
|
|
125
|
+
<key>CFBundleDisplayName</key><string>${display}</string>
|
|
126
|
+
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
|
127
|
+
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
128
|
+
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
|
|
129
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
130
|
+
<key>CFBundleShortVersionString</key><string>0.1</string>
|
|
131
|
+
<key>CFBundleVersion</key><string>1</string>
|
|
132
|
+
<key>LSRequiresIPhoneOS</key><true/>
|
|
133
|
+
<key>UIRequiresFullScreen</key><true/>
|
|
134
|
+
<key>UIStatusBarHidden</key><true/>
|
|
135
|
+
<key>UIViewControllerBasedStatusBarAppearance</key><false/>
|
|
136
|
+
<key>UILaunchScreen</key><dict/>
|
|
137
|
+
<key>UISupportedInterfaceOrientations</key>
|
|
138
|
+
<array>
|
|
139
|
+
${orientations.map((o) => ` <string>${o}</string>`).join("\n")}
|
|
140
|
+
</array>
|
|
141
|
+
</dict>
|
|
142
|
+
</plist>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function projectYml(o: { name: string; id: string; bundleId: string; team: string; vendor: string; sdl: string; lib: string; icon: boolean }): string {
|
|
147
|
+
const frameworks = ["UIKit", "Foundation", "Metal", "QuartzCore", "AVFoundation", "AudioToolbox", "CoreAudio", "CoreBluetooth", "CoreGraphics", "CoreHaptics", "CoreMotion", "GameController", "IOSurface", "UniformTypeIdentifiers", "CoreMedia", "CoreVideo", "OpenGLES"];
|
|
148
|
+
return `name: ${o.name}
|
|
149
|
+
options:
|
|
150
|
+
deploymentTarget:
|
|
151
|
+
iOS: "17.0"
|
|
152
|
+
targets:
|
|
153
|
+
${o.name}:
|
|
154
|
+
type: application
|
|
155
|
+
platform: iOS
|
|
156
|
+
sources:
|
|
157
|
+
- path: host/main.c
|
|
158
|
+
- path: ${o.id}_glue.c
|
|
159
|
+
- path: ${join(ENGINE, "native", "df_native.c")}
|
|
160
|
+
- path: ${join(ENGINE, "native", "df_audio.c")}
|
|
161
|
+
- path: game
|
|
162
|
+
type: folder
|
|
163
|
+
buildPhase: resources
|
|
164
|
+
${o.icon ? " - path: Assets.xcassets\n" : ""} settings:
|
|
165
|
+
base:
|
|
166
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${o.bundleId}
|
|
167
|
+
DEVELOPMENT_TEAM: ${o.team}
|
|
168
|
+
CODE_SIGN_STYLE: Automatic
|
|
169
|
+
INFOPLIST_FILE: host/Info.plist
|
|
170
|
+
${o.icon ? " ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon\n" : ""} TARGETED_DEVICE_FAMILY: "1,2"
|
|
171
|
+
HEADER_SEARCH_PATHS:
|
|
172
|
+
- ${join(o.vendor, o.sdl, "include")}
|
|
173
|
+
- ${join(o.vendor, "wgpu", "ios", "include")}
|
|
174
|
+
- ${join(ENGINE, "native")}
|
|
175
|
+
OTHER_LDFLAGS:
|
|
176
|
+
- ${o.lib}
|
|
177
|
+
- ${join(o.vendor, sdlLibrary("ios"))}
|
|
178
|
+
- ${join(o.vendor, "wgpu", "ios", "lib", "libwgpu_native.a")}
|
|
179
|
+
- -lc++
|
|
180
|
+
- -liconv
|
|
181
|
+
GCC_WARN_INHIBIT_ALL_WARNINGS: YES
|
|
182
|
+
ENABLE_BITCODE: NO
|
|
183
|
+
dependencies:
|
|
184
|
+
${frameworks.map((f) => ` - sdk: ${f}.framework`).join("\n")}
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function buildIos(ctx: Ctx, root: string, gameName: string, targetName: string, t: IosTarget): Promise<{ app: string; log: string; steps: RunResult[] }> {
|
|
189
|
+
for (const tool of ["scriptc", "xcodegen", "xcodebuild", "sips"]) {
|
|
190
|
+
if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "xcodegen" ? "brew install xcodegen" : tool === "scriptc" ? "npm i -g scriptc" : "install Xcode", SKILL);
|
|
191
|
+
}
|
|
192
|
+
for (const key of ["entry", "bundleId", "team"] as const) {
|
|
193
|
+
if (!t[key]) throw new CliError("BAD_CONFIG", `targets.${targetName}.native.${key} is required for iOS`, `dotframe config set targets.${targetName}.native.${key} '"..."'`, SKILL);
|
|
194
|
+
}
|
|
195
|
+
if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `iOS entry ${t.entry} does not exist`, "add it (see the ios skill: export init and frame)", SKILL);
|
|
196
|
+
const vendor = vendorStatus("ios");
|
|
197
|
+
if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
|
|
198
|
+
const pack = await iosRuntimePack(ctx, root);
|
|
199
|
+
if (!pack.path) {
|
|
200
|
+
throw new CliError("TOOL_MISSING", pack.found ? `scriptc iOS runtime pack is ${pack.found}, scriptc is ${pack.want}` : `scriptc iOS runtime pack ${pack.want} not found`, iosPackFix(pack.want), SKILL);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const display = t.displayName ?? gameName;
|
|
204
|
+
const id = identifier(gameName);
|
|
205
|
+
const name = scheme(display);
|
|
206
|
+
const stage = join(root, ".dotframe", "native", targetName);
|
|
207
|
+
const out = join(root, "dist", targetName);
|
|
208
|
+
const app = join(out, `${name}.app`);
|
|
209
|
+
const logDir = join(root, ".dotframe", "logs");
|
|
210
|
+
const log = join(logDir, `build-${targetName}.log`);
|
|
211
|
+
if (ctx.dryRun) {
|
|
212
|
+
print(ctx, { dryRun: true, platform: "ios", stage, app, scheme: name, bundleId: t.bundleId, team: t.team, runtimePack: pack.path }, (): string => `would stage ${t.entry} in ${stage} and build ${app}`);
|
|
213
|
+
return { app, log, steps: [] };
|
|
214
|
+
}
|
|
215
|
+
rmSync(stage, { recursive: true, force: true });
|
|
216
|
+
mkdirSync(join(stage, "host"), { recursive: true });
|
|
217
|
+
mkdirSync(logDir, { recursive: true });
|
|
218
|
+
const engineStage = join(stage, "dotframe");
|
|
219
|
+
cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
|
|
220
|
+
const gameStage = stageGame(root, t.entry, join(stage, "tree"), engineStage);
|
|
221
|
+
writeFileSync(
|
|
222
|
+
join(stage, "app.json"),
|
|
223
|
+
JSON.stringify({ name: id, entry: relative(stage, join(gameStage, t.entry)), exports: [{ export: "init", params: ["string"], returns: "void" }, { export: "frame", params: ["f64"], returns: "bool" }] }, null, 2),
|
|
224
|
+
);
|
|
225
|
+
writeFileSync(join(stage, "host", "main.c"), hostC(id, display));
|
|
226
|
+
writeFileSync(join(stage, "host", "Info.plist"), infoPlist(display, t.orientation ?? "landscape"));
|
|
227
|
+
// Bundle resources: the game's assets and the engine fonts, under <app>/game.
|
|
228
|
+
const bundle = join(stage, "game");
|
|
229
|
+
mkdirSync(bundle, { recursive: true });
|
|
230
|
+
const assets = t.assets ?? "assets";
|
|
231
|
+
if (existsSync(join(root, assets))) cpSync(join(root, assets), join(bundle, assets), { recursive: true });
|
|
232
|
+
cpSync(join(ENGINE, "assets", "fonts"), join(bundle, "dotframe", "assets", "fonts"), { recursive: true });
|
|
233
|
+
|
|
234
|
+
const steps: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }[] = [];
|
|
235
|
+
if (t.icon) {
|
|
236
|
+
const icons = join(stage, "Assets.xcassets", "AppIcon.appiconset");
|
|
237
|
+
mkdirSync(icons, { recursive: true });
|
|
238
|
+
writeFileSync(join(stage, "Assets.xcassets", "Contents.json"), '{"info":{"author":"xcode","version":1}}\n');
|
|
239
|
+
writeFileSync(join(icons, "Contents.json"), '{"images":[{"filename":"icon-1024.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}\n');
|
|
240
|
+
steps.push({ label: "icon", argv: ["sips", "-z", "1024", "1024", resolve(root, t.icon), "--out", join(icons, "icon-1024.png")] });
|
|
241
|
+
}
|
|
242
|
+
steps.push(
|
|
243
|
+
{ label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage] },
|
|
244
|
+
{ label: "scriptc library", argv: ["scriptc", "build", "--lib", "--profile", `${id}.profile.json`], cwd: stage, env: { SCRIPTC_TARGET: "aarch64-apple-ios", SCRIPTC_RUNTIME_PACK: pack.path } },
|
|
245
|
+
);
|
|
246
|
+
const results: RunResult[] = [];
|
|
247
|
+
const full: string[] = [];
|
|
248
|
+
const run = async (step: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }): Promise<void> => {
|
|
249
|
+
if (!ctx.json) console.error(`> ${step.label}`);
|
|
250
|
+
const r = await exec(ctx, root, step);
|
|
251
|
+
full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
|
|
252
|
+
writeFileSync(log, full.join("\n"));
|
|
253
|
+
results.push(r);
|
|
254
|
+
if (r.code !== 0) {
|
|
255
|
+
const count = r.output.match(/(\d+) errors?/)?.[1];
|
|
256
|
+
throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}${count ? ` with ${count} errors` : ""}; full output in ${log}\n${r.tail}`, `read ${log}`, SKILL, 1, log);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
for (const step of steps) await run(step);
|
|
260
|
+
const lib = readdirSync(join(stage, ".scriptc")).find((f: string): boolean => f.endsWith(".lib.a"));
|
|
261
|
+
if (!lib) throw new CliError("STEP_FAILED", "scriptc produced no .lib.a", `read ${log}`, SKILL, 1, log);
|
|
262
|
+
writeFileSync(join(stage, "project.yml"), projectYml({ name, id, bundleId: t.bundleId, team: t.team, vendor: vendor.dir, sdl: vendor.sdl ?? "", lib: join(stage, ".scriptc", lib), icon: !!t.icon }));
|
|
263
|
+
await run({ label: "xcodegen", argv: ["xcodegen", "generate"], cwd: stage });
|
|
264
|
+
await run({ label: "xcodebuild", argv: ["xcodebuild", "-scheme", name, "-destination", "generic/platform=iOS", "-allowProvisioningUpdates", "-derivedDataPath", join(stage, "derived"), "build", "-quiet"], cwd: stage });
|
|
265
|
+
const built = join(stage, "derived", "Build", "Products", "Debug-iphoneos", `${name}.app`);
|
|
266
|
+
if (!existsSync(built)) throw new CliError("STEP_FAILED", `xcodebuild finished but ${built} is missing`, `read ${log}`, SKILL, 1, log);
|
|
267
|
+
rmSync(app, { recursive: true, force: true });
|
|
268
|
+
mkdirSync(out, { recursive: true });
|
|
269
|
+
cpSync(built, app, { recursive: true });
|
|
270
|
+
return { app, log, steps: results };
|
|
271
|
+
}
|
package/cli/lib.ts
CHANGED
|
@@ -53,11 +53,11 @@ export interface Command {
|
|
|
53
53
|
export interface Target {
|
|
54
54
|
steps?: Command[];
|
|
55
55
|
// Native targets built by the CLI itself (staged engine, vendored SDL3 and wgpu-native).
|
|
56
|
-
native?: import("./native").NativeTarget;
|
|
56
|
+
native?: import("./native").NativeTarget | import("./ios").IosTarget;
|
|
57
57
|
out?: string;
|
|
58
58
|
app?: string;
|
|
59
59
|
device?: string;
|
|
60
|
-
deploy?: { provider: "vercel"; project: string; scope: string };
|
|
60
|
+
deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export interface Config {
|
|
@@ -68,6 +68,8 @@ export interface Config {
|
|
|
68
68
|
relay?: { provider: "dokploy"; compose: string } | { provider: "fly"; app: string; config: string; region?: string };
|
|
69
69
|
links?: { path: string; target: string }[];
|
|
70
70
|
assets?: { localOnly?: string[] };
|
|
71
|
+
// Tools the build needs besides bun (for example ffmpeg); doctor checks them and docker images install them.
|
|
72
|
+
requires?: string[];
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export const CONFIG_FILE = "dotframe.json";
|
package/cli/main.ts
CHANGED
|
@@ -3,12 +3,15 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import pkg from "../package.json" with { type: "json" };
|
|
4
4
|
import { build, deploy, device, relay, vendor } from "./commands/ship";
|
|
5
5
|
import { doctor } from "./commands/doctor";
|
|
6
|
+
import { deployInit } from "./commands/docker";
|
|
6
7
|
import { configCmd } from "./commands/config";
|
|
7
8
|
import { desync, record, sim, verify } from "./commands/play";
|
|
8
9
|
import { snap } from "./commands/snap";
|
|
10
|
+
import { playOnline } from "./commands/online";
|
|
9
11
|
import { skills } from "./commands/skills";
|
|
10
12
|
import { create, dev } from "./commands/new";
|
|
11
|
-
import { CliError, type Ctx, fail, num } from "./lib";
|
|
13
|
+
import { CliError, type Ctx, fail, num, print } from "./lib";
|
|
14
|
+
import { startRelay } from "./relay";
|
|
12
15
|
|
|
13
16
|
const HELP = `dotframe ${pkg.version}: build, test, and export dotframe games
|
|
14
17
|
|
|
@@ -18,15 +21,18 @@ Play (headless, deterministic)
|
|
|
18
21
|
sim [--inputs f.jsonl | --mash <seed>] [--frames 600] [--seed 1] [--options json] [--every n]
|
|
19
22
|
snap --frame <n> [--out frame.png] [--inputs f | --mash <seed>] [--seed 1] [--options json]
|
|
20
23
|
replay record <file> | verify <file...>
|
|
24
|
+
play --online [room] [--frames 600] two browsers, local relay, scripted match
|
|
21
25
|
desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3]
|
|
22
26
|
|
|
23
27
|
Build and ship
|
|
24
28
|
build <target> [--release] targets come from dotframe.json
|
|
25
29
|
deploy <target> [--prod] gated: --yes, preview with --dry-run
|
|
30
|
+
deploy init <target> --provider dokploy [--compose id] Dockerfiles, nginx, compose
|
|
31
|
+
relay serve [--port 8787] local netplay relay (same protocol as production)
|
|
26
32
|
relay deploy [--region eze] gated
|
|
27
33
|
device install <target> gated
|
|
28
34
|
vendor <macos|windows> SDL3 + wgpu-native for native builds (~/.dotframe/vendor)
|
|
29
|
-
doctor [--fix]
|
|
35
|
+
doctor [--fix] [--docker] toolchain, vendor, links, config, sim render and math
|
|
30
36
|
config get [key] | set <key> <json>
|
|
31
37
|
|
|
32
38
|
Project
|
|
@@ -68,6 +74,11 @@ Compares checksums every frame and the full inspect() state every --every frames
|
|
|
68
74
|
Warns when rollbacks exceed the sim's rollbackWindow.
|
|
69
75
|
|
|
70
76
|
dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
|
|
77
|
+
play: `dotframe play --online [room] [--frames 600] [--out .dotframe/play] [--json]
|
|
78
|
+
|
|
79
|
+
Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=1|2.
|
|
80
|
+
Waits until both confirm --frames frames, compares their checksums every 30 frames, and saves a screenshot of each.
|
|
81
|
+
The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe (templates do).`,
|
|
71
82
|
build: `dotframe build <target> [--release] [--dry-run] [--json]
|
|
72
83
|
|
|
73
84
|
Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
|
|
@@ -77,14 +88,23 @@ Failures keep the full output in .dotframe/logs and return its path. --release r
|
|
|
77
88
|
dotframe build web
|
|
78
89
|
dotframe build ios --release`,
|
|
79
90
|
deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
|
|
91
|
+
dotframe deploy init <target> --provider dokploy [--compose <id>] [--yes]
|
|
92
|
+
|
|
93
|
+
provider vercel: uploads targets.<target>.out. provider dokploy: redeploys the compose stack (it builds from the
|
|
94
|
+
pushed branch), waits for the result, and on failure returns the build log. Without --yes it stops with
|
|
95
|
+
APPROVAL_REQUIRED (exit 2); show the --dry-run plan to a human first.
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
init writes deploy/Dockerfile.web (installs "requires" tools, builds with dotframe), deploy/Dockerfile.relay
|
|
98
|
+
(dotframe relay serve), deploy/nginx.conf (no-cache index.html) and deploy/compose.yaml (web at /, relay at /relay).
|
|
83
99
|
|
|
84
|
-
dotframe deploy web --prod --dry-run
|
|
85
|
-
|
|
100
|
+
dotframe deploy web --prod --dry-run
|
|
101
|
+
dotframe deploy init web --provider dokploy`,
|
|
102
|
+
relay: `dotframe relay serve [--port 8787]
|
|
103
|
+
dotframe relay deploy [--region eze] [--dry-run] [--yes]
|
|
86
104
|
|
|
87
|
-
|
|
105
|
+
serve runs the netplay relay locally (PORT env or --port): it pairs two clients per ?room= and forwards their
|
|
106
|
+
messages, the protocol connectRelay in dotframe/src/netplay speaks. deploy redeploys the production relay
|
|
107
|
+
(dokploy) or deploys it to a region (fly), gated like deploy.`,
|
|
88
108
|
device: `dotframe device install <target> [--dry-run] [--yes]
|
|
89
109
|
|
|
90
110
|
Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
|
|
@@ -92,10 +112,11 @@ Installs targets.<target>.app on targets.<target>.device with devicectl. Gated l
|
|
|
92
112
|
|
|
93
113
|
Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
|
|
94
114
|
survives reinstalling dotframe and is shared by every game.`,
|
|
95
|
-
doctor: `dotframe doctor [--fix] [--json]
|
|
115
|
+
doctor: `dotframe doctor [--fix] [--docker] [--json]
|
|
96
116
|
|
|
97
|
-
Checks tools
|
|
98
|
-
--fix creates missing vendor
|
|
117
|
+
Checks tools (including "requires" in dotframe.json), vendor, links, placeholders, the sim (render with a stub
|
|
118
|
+
Draw2D leaves state alone) and platform-dependent math in code the sim reaches. --fix creates missing vendor
|
|
119
|
+
symlinks; --docker builds the dokploy web image locally, as the server would.`,
|
|
99
120
|
config: `dotframe config get [key]
|
|
100
121
|
dotframe config set <key> <json> [--dry-run]
|
|
101
122
|
|
|
@@ -145,6 +166,10 @@ const { values, positionals } = parseArgs({
|
|
|
145
166
|
port: { type: "string" },
|
|
146
167
|
"no-install": { type: "boolean" },
|
|
147
168
|
"through-over": { type: "boolean" },
|
|
169
|
+
docker: { type: "boolean" },
|
|
170
|
+
online: { type: "boolean" },
|
|
171
|
+
provider: { type: "string" },
|
|
172
|
+
compose: { type: "string" },
|
|
148
173
|
},
|
|
149
174
|
});
|
|
150
175
|
|
|
@@ -156,17 +181,25 @@ try {
|
|
|
156
181
|
if (values.version) console.log(pkg.version);
|
|
157
182
|
else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
|
|
158
183
|
else if (!command || values.help) console.log(HELP);
|
|
184
|
+
else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out });
|
|
159
185
|
else if (command === "sim") await sim(ctx, v);
|
|
160
186
|
else if (command === "snap") await snap(ctx, v);
|
|
161
187
|
else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
|
|
162
188
|
else if (command === "replay" && rest[0] === "verify") await verify(ctx, rest.slice(1));
|
|
163
189
|
else if (command === "desync") await desync(ctx, v);
|
|
164
190
|
else if (command === "build") await build(ctx, rest[0], values.release === true);
|
|
191
|
+
else if (command === "deploy" && rest[0] === "init") await deployInit(ctx, rest[1], v.provider, v.compose);
|
|
165
192
|
else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
|
|
166
193
|
else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
|
|
194
|
+
else if (command === "relay" && rest[0] === "serve") {
|
|
195
|
+
const port = num("port", v.port, Number(process.env.PORT ?? "8787"), 1);
|
|
196
|
+
const server = startRelay(port, (line: string): void => console.error(line));
|
|
197
|
+
print(ctx, { relay: `ws://localhost:${server.port}`, port: server.port }, (): string => `relay listening on ws://localhost:${server.port} (Ctrl+C to stop)`);
|
|
198
|
+
await new Promise((): void => {});
|
|
199
|
+
}
|
|
167
200
|
else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
|
|
168
201
|
else if (command === "vendor") await vendor(ctx, rest[0]);
|
|
169
|
-
else if (command === "doctor") await doctor(ctx, values.fix === true);
|
|
202
|
+
else if (command === "doctor") await doctor(ctx, values.fix === true, values.docker === true);
|
|
170
203
|
else if (command === "config") await configCmd(ctx, rest);
|
|
171
204
|
else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
|
|
172
205
|
else if (command === "new") await create(ctx, rest[0], v.template ?? "blank", values["no-install"] !== true);
|
package/cli/native.ts
CHANGED
|
@@ -6,10 +6,10 @@ import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./
|
|
|
6
6
|
// The engine this CLI belongs to: a git checkout or node_modules/dotframe.
|
|
7
7
|
export const ENGINE = resolve(import.meta.dir, "..");
|
|
8
8
|
|
|
9
|
-
export type NativePlatform = "macos" | "windows";
|
|
9
|
+
export type NativePlatform = "macos" | "windows" | "ios";
|
|
10
10
|
|
|
11
11
|
export interface NativeTarget {
|
|
12
|
-
platform:
|
|
12
|
+
platform: "macos" | "windows";
|
|
13
13
|
// Entry module, relative to the game root.
|
|
14
14
|
entry: string;
|
|
15
15
|
// Binary name; defaults to the game name.
|
|
@@ -34,12 +34,17 @@ export interface VendorStatus {
|
|
|
34
34
|
export function vendorStatus(platform: NativePlatform): VendorStatus {
|
|
35
35
|
const dir = vendorDir(platform);
|
|
36
36
|
const sdl = existsSync(dir) ? (readdirSync(dir).find((d: string): boolean => d.startsWith("SDL3-")) ?? null) : null;
|
|
37
|
-
const need = [`wgpu/${platform}/lib/libwgpu_native.a`,
|
|
37
|
+
const need = [`wgpu/${platform}/lib/libwgpu_native.a`, sdlLibrary(platform)];
|
|
38
38
|
const missing = need.filter((p: string): boolean => !existsSync(join(dir, p)));
|
|
39
39
|
if (!sdl) missing.push("SDL3-<version> (headers)");
|
|
40
40
|
return { dir, missing, sdl };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Relative to the vendor dir; iOS builds with the Xcode generator.
|
|
44
|
+
export function sdlLibrary(platform: NativePlatform): string {
|
|
45
|
+
return platform === "ios" ? "build/sdl-ios/Release-iphoneos/libSDL3.a" : `build/sdl-${platform}/libSDL3.a`;
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
export function vendorFix(platform: NativePlatform): string {
|
|
44
49
|
return `dotframe vendor ${platform}`;
|
|
45
50
|
}
|
|
@@ -83,7 +88,7 @@ interface Graph {
|
|
|
83
88
|
|
|
84
89
|
// Every source file the entry reaches: relative imports (including ones that leave the game root) and workspace
|
|
85
90
|
// packages. "dotframe/..." is the engine, staged separately.
|
|
86
|
-
function importGraph(entry: string): Graph {
|
|
91
|
+
export function importGraph(entry: string): Graph {
|
|
87
92
|
const files: string[] = [];
|
|
88
93
|
const links = new Map<string, Map<string, string>>();
|
|
89
94
|
const queue = [realpathSync(entry)];
|
|
@@ -123,7 +128,7 @@ function rel(fromFile: string, to: string): string {
|
|
|
123
128
|
// scriptc's static build takes relative imports only, and treats anything under node_modules as package code for
|
|
124
129
|
// its dynamic engine. Staging copies the engine and every reached game file side by side, keeping their relative
|
|
125
130
|
// layout, and rewrites "dotframe/..." and workspace imports to relative paths.
|
|
126
|
-
function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
|
|
131
|
+
export function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
|
|
127
132
|
const graph = importGraph(join(root, entry));
|
|
128
133
|
const base = commonDir([realpathSync(root) + sep + "x", ...graph.files]);
|
|
129
134
|
const staged = (file: string): string => join(tree, relative(base, file));
|
package/cli/relay.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
// Netplay relay: pairs two clients per room and forwards every message to the other one, untouched. Rooms come
|
|
4
|
+
// from the URL (?room=...); in Discord the room is the Activity instance id. Same protocol as connectRelay.
|
|
5
|
+
interface Peer {
|
|
6
|
+
room: string;
|
|
7
|
+
slot: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function startRelay(port: number, log: (line: string) => void = (): void => {}): Server<Peer> {
|
|
11
|
+
const rooms = new Map<string, (ServerWebSocket<Peer> | null)[]>();
|
|
12
|
+
const send = (ws: ServerWebSocket<Peer> | null, message: object): void => {
|
|
13
|
+
if (ws) ws.send(JSON.stringify(message));
|
|
14
|
+
};
|
|
15
|
+
return Bun.serve<Peer>({
|
|
16
|
+
port,
|
|
17
|
+
fetch(request, server) {
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
if (url.pathname.endsWith("/health")) return new Response("ok");
|
|
20
|
+
const room = url.searchParams.get("room") ?? "";
|
|
21
|
+
if (room === "" || room.length > 128) return new Response("room required", { status: 400 });
|
|
22
|
+
const peers = rooms.get(room) ?? [null, null];
|
|
23
|
+
const slot = peers[0] === null ? 0 : peers[1] === null ? 1 : -1;
|
|
24
|
+
if (slot < 0) return new Response("room full", { status: 409 });
|
|
25
|
+
if (server.upgrade(request, { data: { room, slot } })) return undefined;
|
|
26
|
+
return new Response("websocket only", { status: 426 });
|
|
27
|
+
},
|
|
28
|
+
websocket: {
|
|
29
|
+
open(ws) {
|
|
30
|
+
const peers = rooms.get(ws.data.room) ?? [null, null];
|
|
31
|
+
// Two sockets can race for the same free slot between fetch and open.
|
|
32
|
+
if (peers[ws.data.slot] !== null) {
|
|
33
|
+
const other = peers[1 - ws.data.slot] === null ? 1 - ws.data.slot : -1;
|
|
34
|
+
if (other < 0) {
|
|
35
|
+
ws.close(4009, "room full");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ws.data.slot = other;
|
|
39
|
+
}
|
|
40
|
+
peers[ws.data.slot] = ws;
|
|
41
|
+
rooms.set(ws.data.room, peers);
|
|
42
|
+
log(`join room=${ws.data.room} slot=${ws.data.slot}`);
|
|
43
|
+
send(ws, { t: "hello", slot: ws.data.slot });
|
|
44
|
+
const other = peers[1 - ws.data.slot];
|
|
45
|
+
if (other) {
|
|
46
|
+
send(ws, { t: "peer", here: true });
|
|
47
|
+
send(other, { t: "peer", here: true });
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
message(ws, message) {
|
|
51
|
+
const peers = rooms.get(ws.data.room);
|
|
52
|
+
const other = peers ? peers[1 - ws.data.slot] : null;
|
|
53
|
+
if (other) other.send(message);
|
|
54
|
+
},
|
|
55
|
+
close(ws) {
|
|
56
|
+
const peers = rooms.get(ws.data.room);
|
|
57
|
+
if (!peers || peers[ws.data.slot] !== ws) return;
|
|
58
|
+
peers[ws.data.slot] = null;
|
|
59
|
+
log(`leave room=${ws.data.room} slot=${ws.data.slot}`);
|
|
60
|
+
send(peers[1 - ws.data.slot], { t: "peer", here: false });
|
|
61
|
+
if (peers[0] === null && peers[1] === null) rooms.delete(ws.data.room);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
package/native/df_native.c
CHANGED
|
@@ -117,6 +117,9 @@ int32_t df_open(int32_t width, int32_t height, const uint8_t *title, size_t titl
|
|
|
117
117
|
memcpy(title_buf, title, n);
|
|
118
118
|
title_buf[n] = 0;
|
|
119
119
|
|
|
120
|
+
// Touches stay touches: games read input.touches() for them, and a synthesized mouse would also put a phantom
|
|
121
|
+
// pointer under the finger (on iOS, at the release point), which breaks games that read both.
|
|
122
|
+
SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
|
|
120
123
|
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) return -1;
|
|
121
124
|
SDL_WindowFlags flags = SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_RESIZABLE;
|
|
122
125
|
#if defined(__APPLE__)
|
|
@@ -227,6 +230,8 @@ static void mouse_relative(double *nx, double *ny) {
|
|
|
227
230
|
// Field 0 x, 1 y (normalized), 2 buttons (bit 0 left, bit 1 middle, bit 2 right).
|
|
228
231
|
double df_mouse(int32_t field) {
|
|
229
232
|
if (field == 2) {
|
|
233
|
+
// Clicks in other apps must not reach the game: buttons count only while the window has input focus.
|
|
234
|
+
if (!(SDL_GetWindowFlags(g_window) & SDL_WINDOW_INPUT_FOCUS)) return 0;
|
|
230
235
|
SDL_MouseButtonFlags flags = SDL_GetGlobalMouseState(NULL, NULL);
|
|
231
236
|
return (flags & SDL_BUTTON_LMASK ? 1 : 0) | (flags & SDL_BUTTON_MMASK ? 2 : 0) | (flags & SDL_BUTTON_RMASK ? 4 : 0);
|
|
232
237
|
}
|
package/package.json
CHANGED
package/scripts/vendor.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows>
|
|
2
|
+
# Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows|ios>
|
|
3
3
|
# DOTFRAME_VENDOR picks the directory (the dotframe CLI uses ~/.dotframe/vendor); default is this checkout's vendor/.
|
|
4
4
|
set -e
|
|
5
5
|
target=$1
|
|
@@ -13,7 +13,8 @@ mkdir -p "$vendor/build"
|
|
|
13
13
|
case $target in
|
|
14
14
|
macos) wgpu_asset=wgpu-macos-aarch64-release ;;
|
|
15
15
|
windows) wgpu_asset=wgpu-windows-x86_64-gnu-release ;;
|
|
16
|
-
|
|
16
|
+
ios) wgpu_asset=wgpu-ios-aarch64-release ;;
|
|
17
|
+
*) echo "usage: $0 <macos|windows|ios>" >&2; exit 2 ;;
|
|
17
18
|
esac
|
|
18
19
|
|
|
19
20
|
if [ ! -f "$vendor/wgpu/$target/lib/libwgpu_native.a" ]; then
|
|
@@ -27,12 +28,17 @@ if [ ! -d "$vendor/SDL3-$sdl_version" ]; then
|
|
|
27
28
|
curl -fsSL "https://github.com/libsdl-org/SDL/releases/download/release-$sdl_version/SDL3-$sdl_version.tar.gz" | tar xz -C "$vendor"
|
|
28
29
|
fi
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
# iOS builds through the Xcode generator, which puts the archive under Release-iphoneos/.
|
|
32
|
+
sdl_lib="$vendor/build/sdl-$target/libSDL3.a"
|
|
33
|
+
[ "$target" = ios ] && sdl_lib="$vendor/build/sdl-ios/Release-iphoneos/libSDL3.a"
|
|
34
|
+
if [ ! -f "$sdl_lib" ]; then
|
|
35
|
+
generator=""
|
|
31
36
|
case $target in
|
|
32
37
|
macos) extra="-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0" ;;
|
|
33
38
|
windows) extra="-DCMAKE_TOOLCHAIN_FILE=$toolchain/zig-windows.cmake" ;;
|
|
39
|
+
ios) extra="-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=17.0"; generator="-G Xcode" ;;
|
|
34
40
|
esac
|
|
35
|
-
cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" -DCMAKE_BUILD_TYPE=Release \
|
|
41
|
+
cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" $generator -DCMAKE_BUILD_TYPE=Release \
|
|
36
42
|
-DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TEST_LIBRARY=OFF $extra
|
|
37
|
-
cmake --build "$vendor/build/sdl-$target" -j 4
|
|
43
|
+
cmake --build "$vendor/build/sdl-$target" --config Release -j 4
|
|
38
44
|
fi
|
package/skill-data/core/SKILL.md
CHANGED
|
@@ -56,6 +56,12 @@ Prove a check can fail before trusting its green: make render change one field o
|
|
|
56
56
|
|
|
57
57
|
Pass `--json` and read `ok`. Errors look like `{"ok": false, "error": {"code", "message", "fix", "skill"}}`. Apply `fix`, and when it is not obvious run `dotframe skills get <skill>`. Exit codes: 0 ok, 1 failure, 2 approval required. Full list: `dotframe skills get core --full`.
|
|
58
58
|
|
|
59
|
+
## Engine modules agents reach for
|
|
60
|
+
|
|
61
|
+
- `dotframe/src/detmath`: deterministic sin, cos, tan, atan, atan2, exp, log, pow, hypot for simulation code.
|
|
62
|
+
- `dotframe/src/checksum`: a fixed-order checksum over numbers.
|
|
63
|
+
- `dotframe/src/netplay` and `dotframe/src/relay-client`: rollback netplay and the relay connection (see netplay).
|
|
64
|
+
|
|
59
65
|
## Other guides
|
|
60
66
|
|
|
61
67
|
`dotframe skills list`. Load the one that matches the task: netplay, export-web, discord, ios, macos, relay, game-design, assets.
|
|
@@ -19,3 +19,17 @@ dotframe deploy web --prod --yes # only after the human approved the plan
|
|
|
19
19
|
- Content-hash the bundle (`main.<hash>.js`) and serve `index.html` with `Cache-Control: no-cache`, so browsers and Discord's proxy never run a stale build.
|
|
20
20
|
- `deploy` without `--prod` makes a preview. Prefer it for checks.
|
|
21
21
|
- Verify a deploy by opening the URL with agent-browser and taking a screenshot, not by the exit code.
|
|
22
|
+
|
|
23
|
+
## Docker and Dokploy (VPS)
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dotframe deploy init web --provider dokploy # deploy/Dockerfile.web, Dockerfile.relay, nginx.conf, compose.yaml
|
|
27
|
+
dotframe doctor --docker # builds the web image locally, as the server will
|
|
28
|
+
dotframe config set targets.web.deploy.compose '"<compose id>"' # vps compose list
|
|
29
|
+
dotframe deploy web --dry-run && dotframe deploy web --yes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- The web image installs the tools in `requires` (dotframe.json, for example `["ffmpeg"]`) and builds with dotframe; the relay image runs `dotframe relay serve`. The build context is the repo root, so monorepo ports see workspace code.
|
|
33
|
+
- In Dokploy, route the domain's `/` to service `web` (port 80) and `/relay` to service `relay` (port 8787). For Discord, map `/` and `/relay` to that host.
|
|
34
|
+
- Dokploy builds the pushed branch, not your working tree: deploy warns about unpushed or uncommitted work. On failure it returns the build log (through `vps compose logs`).
|
|
35
|
+
- Hash bundles with a tool that exists in the image: `shasum` is missing on Debian images (use `sha256sum` or Bun's hashing, as `scripts/build-web.ts` does).
|