superdario 1.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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/dario +45 -0
- package/dist/main.js +4591 -0
- package/hooks/agent_from_env.sh +9 -0
- package/hooks/agent_hook.sh +9 -0
- package/hooks/notification.sh +8 -0
- package/hooks/post_tool_use.sh +8 -0
- package/hooks/pre_tool_use.sh +8 -0
- package/hooks/stop.sh +8 -0
- package/hooks/user_prompt_submit.sh +8 -0
- package/package.json +48 -0
- package/skills/dario/SKILL.md +116 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,4591 @@
|
|
|
1
|
+
// src/agents/agent_hooks_installer.ts
|
|
2
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// src/config/paths.ts
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
var IS_BUNDLED = basename(import.meta.dirname) === "dist";
|
|
8
|
+
var ROOT_DIR = IS_BUNDLED ? resolve(import.meta.dirname, "..") : resolve(import.meta.dirname, "..", "..");
|
|
9
|
+
var CONFIG_DIR = join(homedir(), ".config", "dario");
|
|
10
|
+
var CLAUDE_DIR = join(homedir(), ".claude");
|
|
11
|
+
var CODEX_DIR = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
12
|
+
var GROK_DIR = process.env.GROK_HOME ?? join(homedir(), ".grok");
|
|
13
|
+
var DARIO_PATHS = {
|
|
14
|
+
rootDir: ROOT_DIR,
|
|
15
|
+
entryFile: IS_BUNDLED ? join(ROOT_DIR, "dist", "main.js") : join(ROOT_DIR, "src", "main.ts"),
|
|
16
|
+
binFile: join(ROOT_DIR, "bin", "dario"),
|
|
17
|
+
hooksDir: join(ROOT_DIR, "hooks"),
|
|
18
|
+
skillDir: join(ROOT_DIR, "skills", "dario"),
|
|
19
|
+
configDir: CONFIG_DIR,
|
|
20
|
+
configFile: join(CONFIG_DIR, "config.json"),
|
|
21
|
+
scoresFile: join(CONFIG_DIR, "scores.json"),
|
|
22
|
+
pidFile: join(CONFIG_DIR, "dario.pid"),
|
|
23
|
+
claudeStatusFile: join(CONFIG_DIR, "claude_status.json"),
|
|
24
|
+
authFile: join(CONFIG_DIR, "auth.json"),
|
|
25
|
+
sponsorsCacheFile: join(CONFIG_DIR, "sponsors.json"),
|
|
26
|
+
claudeSettingsFile: join(CLAUDE_DIR, "settings.json"),
|
|
27
|
+
claudeSkillLink: join(CLAUDE_DIR, "skills", "dario"),
|
|
28
|
+
codexDir: CODEX_DIR,
|
|
29
|
+
codexHooksFile: join(CODEX_DIR, "hooks.json"),
|
|
30
|
+
grokDir: GROK_DIR,
|
|
31
|
+
grokHooksFile: join(GROK_DIR, "hooks", "dario.json"),
|
|
32
|
+
localBinLink: join(homedir(), ".local", "bin", "dario"),
|
|
33
|
+
vsixFile: join(CONFIG_DIR, "dario-launcher.vsix"),
|
|
34
|
+
warpTabConfig: join(homedir(), ".warp", "tab_configs", "dario.toml")
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/agents/agent_hook_definitions.ts
|
|
38
|
+
var AGENT_SCRIPT = "agent_hook.sh";
|
|
39
|
+
var GROK_ATTENTION_MATCHER = "permission_prompt|idle_prompt";
|
|
40
|
+
function bind(agent, event, matcher) {
|
|
41
|
+
const script = `${AGENT_SCRIPT} ${agent} ${event}`;
|
|
42
|
+
return matcher === void 0 ? { script } : { script, matcher };
|
|
43
|
+
}
|
|
44
|
+
var AGENT_HOOK_DEFINITIONS = {
|
|
45
|
+
codex: {
|
|
46
|
+
kind: "codex",
|
|
47
|
+
label: "Codex CLI",
|
|
48
|
+
configDir: DARIO_PATHS.codexDir,
|
|
49
|
+
hooksFile: DARIO_PATHS.codexHooksFile,
|
|
50
|
+
bindings: {
|
|
51
|
+
UserPromptSubmit: bind("codex", "user_prompt_submit"),
|
|
52
|
+
Stop: bind("codex", "stop"),
|
|
53
|
+
PermissionRequest: bind("codex", "attention"),
|
|
54
|
+
PreToolUse: bind("codex", "pre_tool_use"),
|
|
55
|
+
PostToolUse: bind("codex", "post_tool_use")
|
|
56
|
+
},
|
|
57
|
+
afterInstallNote: "Codex only runs hooks you have trusted: open Codex, type /hooks and approve the Dario entries once."
|
|
58
|
+
},
|
|
59
|
+
grok: {
|
|
60
|
+
kind: "grok",
|
|
61
|
+
label: "Grok Build",
|
|
62
|
+
configDir: DARIO_PATHS.grokDir,
|
|
63
|
+
hooksFile: DARIO_PATHS.grokHooksFile,
|
|
64
|
+
bindings: {
|
|
65
|
+
UserPromptSubmit: bind("grok", "user_prompt_submit"),
|
|
66
|
+
Stop: bind("grok", "stop"),
|
|
67
|
+
Notification: bind("grok", "attention", GROK_ATTENTION_MATCHER),
|
|
68
|
+
PreToolUse: bind("grok", "pre_tool_use"),
|
|
69
|
+
PostToolUse: bind("grok", "post_tool_use")
|
|
70
|
+
},
|
|
71
|
+
afterInstallNote: "Grok Build reloads hooks on its next start (or press r on the /hooks tab)."
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/agents/hook_settings_installer.ts
|
|
76
|
+
import { join as join2 } from "node:path";
|
|
77
|
+
|
|
78
|
+
// src/store/json_file.ts
|
|
79
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
80
|
+
import { dirname } from "node:path";
|
|
81
|
+
var JSON_INDENT = 2;
|
|
82
|
+
var JsonFile = class {
|
|
83
|
+
path;
|
|
84
|
+
fallback;
|
|
85
|
+
constructor({ path, fallback }) {
|
|
86
|
+
this.path = path;
|
|
87
|
+
this.fallback = fallback;
|
|
88
|
+
}
|
|
89
|
+
exists() {
|
|
90
|
+
return existsSync(this.path);
|
|
91
|
+
}
|
|
92
|
+
read() {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(readFileSync(this.path, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
return this.fallback;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
write(value) {
|
|
100
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
101
|
+
writeFileSync(this.path, `${JSON.stringify(value, null, JSON_INDENT)}
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/agents/hook_settings_installer.ts
|
|
107
|
+
var HOOK_TIMEOUT_SECONDS = 10;
|
|
108
|
+
var HOOKS_DIR_MARKER = "/hooks/";
|
|
109
|
+
var HookSettingsInstaller = class {
|
|
110
|
+
file;
|
|
111
|
+
hooksDir;
|
|
112
|
+
bindings;
|
|
113
|
+
constructor({ settingsPath, hooksDir, bindings }) {
|
|
114
|
+
this.file = new JsonFile({ path: settingsPath, fallback: {} });
|
|
115
|
+
this.hooksDir = hooksDir;
|
|
116
|
+
this.bindings = bindings;
|
|
117
|
+
}
|
|
118
|
+
isInstalled() {
|
|
119
|
+
const hooks = { ...this.file.read().hooks };
|
|
120
|
+
return Object.keys(this.bindings).every((event) => (hooks[event] ?? []).some((group) => this.isOurs(group)));
|
|
121
|
+
}
|
|
122
|
+
install() {
|
|
123
|
+
const settings = this.file.read();
|
|
124
|
+
const hooks = this.withoutOurs({ ...settings.hooks });
|
|
125
|
+
Object.entries(this.bindings).forEach(([event, binding]) => {
|
|
126
|
+
hooks[event] = [...hooks[event] ?? [], this.createGroup(binding)];
|
|
127
|
+
});
|
|
128
|
+
this.file.write({ ...settings, hooks });
|
|
129
|
+
}
|
|
130
|
+
uninstall() {
|
|
131
|
+
const settings = this.file.read();
|
|
132
|
+
const hooks = this.withoutOurs({ ...settings.hooks });
|
|
133
|
+
const remaining = Object.fromEntries(Object.entries(hooks).filter(([, groups]) => groups.length > 0));
|
|
134
|
+
this.file.write({ ...settings, hooks: remaining });
|
|
135
|
+
}
|
|
136
|
+
/** Ours: a command inside a `hooks/` folder ending with one of our scripts (works for git checkouts and ~/.dario/app). */
|
|
137
|
+
isOurs(group) {
|
|
138
|
+
const scripts = Object.values(this.bindings).map((binding) => binding.script);
|
|
139
|
+
return group.hooks.some((hook) => hook.command.includes(HOOKS_DIR_MARKER) && scripts.some((script) => hook.command.endsWith(`/${script}`)));
|
|
140
|
+
}
|
|
141
|
+
withoutOurs(hooks) {
|
|
142
|
+
return Object.fromEntries(Object.entries(hooks).map(([event, groups]) => [event, groups.filter((group) => !this.isOurs(group))]));
|
|
143
|
+
}
|
|
144
|
+
createGroup(binding) {
|
|
145
|
+
const command = join2(this.hooksDir, binding.script);
|
|
146
|
+
const entry = { hooks: [{ type: "command", command, timeout: HOOK_TIMEOUT_SECONDS }] };
|
|
147
|
+
return binding.matcher === void 0 ? entry : { matcher: binding.matcher, ...entry };
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// src/agents/agent_hooks_installer.ts
|
|
152
|
+
var AgentHooksInstaller = class {
|
|
153
|
+
hooksDir;
|
|
154
|
+
definitions;
|
|
155
|
+
constructor({ hooksDir = DARIO_PATHS.hooksDir, definitions = AGENT_HOOK_DEFINITIONS } = {}) {
|
|
156
|
+
this.hooksDir = hooksDir;
|
|
157
|
+
this.definitions = definitions;
|
|
158
|
+
}
|
|
159
|
+
listKinds() {
|
|
160
|
+
return Object.keys(this.definitions);
|
|
161
|
+
}
|
|
162
|
+
/** The agent has been used on this machine, so its hook file is worth writing. */
|
|
163
|
+
isAvailable(kind) {
|
|
164
|
+
return existsSync2(this.definitions[kind].configDir);
|
|
165
|
+
}
|
|
166
|
+
isInstalled(kind) {
|
|
167
|
+
return this.createInstaller(kind).isInstalled();
|
|
168
|
+
}
|
|
169
|
+
describe(kind) {
|
|
170
|
+
const definition = this.definitions[kind];
|
|
171
|
+
const state = this.isInstalled(kind) ? "installed" : this.isAvailable(kind) ? "not installed" : "agent not found";
|
|
172
|
+
return { kind, label: definition.label, state, hooksFile: definition.hooksFile };
|
|
173
|
+
}
|
|
174
|
+
install(kind) {
|
|
175
|
+
this.createInstaller(kind).install();
|
|
176
|
+
return this.describe(kind);
|
|
177
|
+
}
|
|
178
|
+
uninstall(kind) {
|
|
179
|
+
if (existsSync2(this.definitions[kind].hooksFile)) this.createInstaller(kind).uninstall();
|
|
180
|
+
return this.describe(kind);
|
|
181
|
+
}
|
|
182
|
+
/** Installs into every agent found on this machine and removes from every agent, used by `dario install` / `dario uninstall`. */
|
|
183
|
+
installAvailable() {
|
|
184
|
+
return this.listKinds().map((kind) => this.isAvailable(kind) ? this.install(kind) : this.describe(kind));
|
|
185
|
+
}
|
|
186
|
+
uninstallAll() {
|
|
187
|
+
return this.listKinds().map((kind) => this.uninstall(kind));
|
|
188
|
+
}
|
|
189
|
+
getNote(kind) {
|
|
190
|
+
return this.definitions[kind].afterInstallNote;
|
|
191
|
+
}
|
|
192
|
+
createInstaller(kind) {
|
|
193
|
+
const definition = this.definitions[kind];
|
|
194
|
+
return new HookSettingsInstaller({ settingsPath: definition.hooksFile, hooksDir: this.hooksDir, bindings: definition.bindings });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// src/cli/describe_agent_reports.ts
|
|
199
|
+
var LABEL_WIDTH = 15;
|
|
200
|
+
function describeAgentReports(reports) {
|
|
201
|
+
return reports.map((report) => {
|
|
202
|
+
const detail = report.state === "agent not found" ? `not found (${report.label} is not on this Mac)` : `${report.state} (${report.hooksFile})`;
|
|
203
|
+
return ` ${`${report.kind} hooks`.padEnd(LABEL_WIDTH)}${detail}`;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/cli/commands/agents_command.ts
|
|
208
|
+
var OFF = "off";
|
|
209
|
+
function isHookedAgent(value, installer) {
|
|
210
|
+
return installer.listKinds().includes(value);
|
|
211
|
+
}
|
|
212
|
+
var AGENTS_COMMAND = {
|
|
213
|
+
name: "agents",
|
|
214
|
+
aliases: ["integrations"],
|
|
215
|
+
usage: "dario agents [codex|grok] [off]",
|
|
216
|
+
description: 'Show or wire the Codex CLI / Grok Build hooks so their prompts open the game too (Claude Code is wired by "dario install").',
|
|
217
|
+
async execute(args) {
|
|
218
|
+
const installer = new AgentHooksInstaller();
|
|
219
|
+
const value = (args[0] ?? "").toLowerCase();
|
|
220
|
+
if (value.length === 0) {
|
|
221
|
+
describeAgentReports(installer.listKinds().map((kind) => installer.describe(kind))).forEach((line) => console.log(line));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if (!isHookedAgent(value, installer)) {
|
|
225
|
+
console.error(`Unknown agent "${value}". Options: ${installer.listKinds().join(", ")}`);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
const isRemoval = (args[1] ?? "").toLowerCase() === OFF;
|
|
229
|
+
const report = isRemoval ? installer.uninstall(value) : installer.install(value);
|
|
230
|
+
describeAgentReports([report]).forEach((line) => console.log(line));
|
|
231
|
+
if (!isRemoval) console.log(` ${installer.getNote(value)}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/store/config_store.ts
|
|
237
|
+
var DEFAULT_CONFIG = { autoLaunch: true, returnFocus: true, terminal: "auto", playerName: "", character: "auto", mode: "vibe", isSetupDone: false };
|
|
238
|
+
var ConfigStore = class {
|
|
239
|
+
file;
|
|
240
|
+
constructor({ path }) {
|
|
241
|
+
this.file = new JsonFile({ path, fallback: {} });
|
|
242
|
+
}
|
|
243
|
+
load() {
|
|
244
|
+
return { ...DEFAULT_CONFIG, ...this.file.read() };
|
|
245
|
+
}
|
|
246
|
+
update(patch) {
|
|
247
|
+
const next = { ...this.load(), ...patch };
|
|
248
|
+
this.file.write(next);
|
|
249
|
+
return next;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// src/cli/commands/create_toggle_command.ts
|
|
254
|
+
var ON_WORDS = /* @__PURE__ */ new Set(["on", "enable", "true", "1"]);
|
|
255
|
+
var OFF_WORDS = /* @__PURE__ */ new Set(["off", "disable", "false", "0"]);
|
|
256
|
+
function createToggleCommand({ name, aliases, usage, description, key, label }) {
|
|
257
|
+
return {
|
|
258
|
+
name,
|
|
259
|
+
aliases,
|
|
260
|
+
usage,
|
|
261
|
+
description,
|
|
262
|
+
async execute(args) {
|
|
263
|
+
const word = (args[0] ?? "").toLowerCase();
|
|
264
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
265
|
+
if (!ON_WORDS.has(word) && !OFF_WORDS.has(word)) {
|
|
266
|
+
console.log(`${label} is ${store.load()[key] ? "ON" : "OFF"}. Use "${usage}".`);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
const config = store.update({ [key]: ON_WORDS.has(word) });
|
|
270
|
+
console.log(`${label} is now ${config[key] ? "ON" : "OFF"}.`);
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/cli/commands/auto_launch_command.ts
|
|
277
|
+
var AUTO_LAUNCH_COMMAND = createToggleCommand({
|
|
278
|
+
name: "auto",
|
|
279
|
+
aliases: ["on", "off", "enable", "disable"],
|
|
280
|
+
usage: "dario on | dario off",
|
|
281
|
+
description: 'Turn the "open Dario on every Claude prompt" behaviour on or off.',
|
|
282
|
+
key: "autoLaunch",
|
|
283
|
+
label: "Auto-launch"
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// src/cli/commands/focus_command.ts
|
|
287
|
+
var FOCUS_COMMAND = createToggleCommand({
|
|
288
|
+
name: "focus",
|
|
289
|
+
aliases: [],
|
|
290
|
+
usage: "dario focus <on|off>",
|
|
291
|
+
description: "When Claude finishes or needs input: pause the game and jump back to the app you prompted from.",
|
|
292
|
+
key: "returnFocus",
|
|
293
|
+
label: "Return-focus"
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// src/player/resolve_agent.ts
|
|
297
|
+
var AGENT_KINDS = ["claude", "codex", "grok"];
|
|
298
|
+
var AS_FLAG = "--as";
|
|
299
|
+
function isAgentKind(value) {
|
|
300
|
+
return AGENT_KINDS.includes(value);
|
|
301
|
+
}
|
|
302
|
+
function readFlag(args) {
|
|
303
|
+
const index = args.indexOf(AS_FLAG);
|
|
304
|
+
const value = index >= 0 ? args[index + 1] : args.find((arg) => arg.startsWith(`${AS_FLAG}=`))?.slice(AS_FLAG.length + 1);
|
|
305
|
+
return isAgentKind(value) ? value : null;
|
|
306
|
+
}
|
|
307
|
+
function detectFromEnvironment(env) {
|
|
308
|
+
if (env.CLAUDECODE !== void 0 || env.CLAUDE_CODE_ENTRYPOINT !== void 0) return "claude";
|
|
309
|
+
if (env.CODEX_SANDBOX !== void 0 || env.CODEX_HOME !== void 0 || env.CODEX_CLI !== void 0) return "codex";
|
|
310
|
+
if (env.GROK_CLI !== void 0 || env.GROK_API_KEY !== void 0) return "grok";
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function resolveAgent({ args, configured, env }) {
|
|
314
|
+
return readFlag(args) ?? (configured === "auto" ? null : configured) ?? detectFromEnvironment(env) ?? "claude";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/cli/commands/character_command.ts
|
|
318
|
+
var OPTIONS = ["auto", ...AGENT_KINDS];
|
|
319
|
+
var CHARACTER_COMMAND = {
|
|
320
|
+
name: "character",
|
|
321
|
+
aliases: ["as", "agent"],
|
|
322
|
+
usage: `dario character <${OPTIONS.join("|")}>`,
|
|
323
|
+
description: "Choose who you play as; the other agents become the enemies (auto = detect Claude/Codex/Grok).",
|
|
324
|
+
async execute(args) {
|
|
325
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
326
|
+
const value = (args[0] ?? "").toLowerCase();
|
|
327
|
+
if (value.length === 0) {
|
|
328
|
+
console.log(`Character: ${store.load().character}`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (value !== "auto" && !isAgentKind(value)) {
|
|
332
|
+
console.error(`Unknown character "${value}". Options: ${OPTIONS.join(", ")}`);
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
console.log(`Character is now ${store.update({ character: value }).character}.`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// src/cli/commands/help_command.ts
|
|
341
|
+
var USAGE_WIDTH = 34;
|
|
342
|
+
function createHelpCommand({ commands }) {
|
|
343
|
+
return {
|
|
344
|
+
name: "help",
|
|
345
|
+
aliases: ["--help", "-h"],
|
|
346
|
+
usage: "dario help",
|
|
347
|
+
description: "Show this help.",
|
|
348
|
+
async execute() {
|
|
349
|
+
const lines = commands().map((command) => ` ${command.usage.padEnd(USAGE_WIDTH)} ${command.description}`);
|
|
350
|
+
console.log(["Dario - Claude's mascot endless runner", "", "Usage: dario [command] (no command = play)", "", ...lines].join("\n"));
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/render/ansi.ts
|
|
357
|
+
var ESC = "\x1B";
|
|
358
|
+
var CSI = `${ESC}[`;
|
|
359
|
+
var OSC = `${ESC}]`;
|
|
360
|
+
var STRING_TERMINATOR = `${ESC}\\`;
|
|
361
|
+
var ANSI = {
|
|
362
|
+
reset: `${CSI}0m`,
|
|
363
|
+
bold: `${CSI}1m`,
|
|
364
|
+
dim: `${CSI}2m`,
|
|
365
|
+
hideCursor: `${CSI}?25l`,
|
|
366
|
+
showCursor: `${CSI}?25h`,
|
|
367
|
+
enterAltScreen: `${CSI}?1049h`,
|
|
368
|
+
exitAltScreen: `${CSI}?1049l`,
|
|
369
|
+
home: `${CSI}H`,
|
|
370
|
+
/** DECCKM application cursor keys: arrows arrive as ESC O A..D and are never echoed by the terminal. */
|
|
371
|
+
applicationCursorKeysOn: `${CSI}?1h`,
|
|
372
|
+
applicationCursorKeysOff: `${CSI}?1l`,
|
|
373
|
+
/** Kitty keyboard protocol: disambiguate keys (1) + report press/repeat/release (2). Ignored by terminals without it. */
|
|
374
|
+
kittyKeyboardOn: `${CSI}>3u`,
|
|
375
|
+
kittyKeyboardOff: `${CSI}<u`,
|
|
376
|
+
kittyKeyboardQuery: `${CSI}?u`,
|
|
377
|
+
clearScreen: `${CSI}2J`,
|
|
378
|
+
fg: (color) => `${CSI}38;2;${color.r};${color.g};${color.b}m`,
|
|
379
|
+
bg: (color) => `${CSI}48;2;${color.r};${color.g};${color.b}m`,
|
|
380
|
+
/** OSC 8 hyperlink: supported by Warp, iTerm2, VS Code, kitty, WezTerm; ignored elsewhere. */
|
|
381
|
+
linkOpen: (url) => `${OSC}8;;${url}${STRING_TERMINATOR}`,
|
|
382
|
+
linkClose: `${OSC}8;;${STRING_TERMINATOR}`
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/cli/commands/keys_command.ts
|
|
386
|
+
var PROBE_SECONDS = 8;
|
|
387
|
+
var MS_PER_SECOND = 1e3;
|
|
388
|
+
var KEYS_COMMAND = {
|
|
389
|
+
name: "keys",
|
|
390
|
+
aliases: ["probe"],
|
|
391
|
+
usage: "dario keys",
|
|
392
|
+
description: `Print raw key events for ${PROBE_SECONDS} seconds (to debug arrow keys / key release support).`,
|
|
393
|
+
async execute() {
|
|
394
|
+
if (!process.stdin.isTTY) {
|
|
395
|
+
console.error("dario keys needs an interactive terminal.");
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
process.stdout.write(`${ANSI.kittyKeyboardOn}${ANSI.kittyKeyboardQuery}Press and hold RIGHT, release, then SPACE. Listening for ${PROBE_SECONDS}s...
|
|
399
|
+
`);
|
|
400
|
+
process.stdin.setRawMode(true);
|
|
401
|
+
process.stdin.setEncoding("utf8");
|
|
402
|
+
process.stdin.on("data", (chunk) => process.stdout.write(`${JSON.stringify(chunk)}\r
|
|
403
|
+
`));
|
|
404
|
+
process.stdin.resume();
|
|
405
|
+
await new Promise((resolve2) => setTimeout(resolve2, PROBE_SECONDS * MS_PER_SECOND));
|
|
406
|
+
process.stdout.write(ANSI.kittyKeyboardOff);
|
|
407
|
+
process.stdin.setRawMode(false);
|
|
408
|
+
process.stdin.pause();
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// src/launcher/detect_terminal.ts
|
|
414
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
415
|
+
|
|
416
|
+
// src/ide/ide_definitions.ts
|
|
417
|
+
import { homedir as homedir2 } from "node:os";
|
|
418
|
+
import { join as join3 } from "node:path";
|
|
419
|
+
var APPLICATIONS_DIR = "/Applications";
|
|
420
|
+
function define({ kind, appName, scheme, cli, homeDir }) {
|
|
421
|
+
const appBundle = join3(APPLICATIONS_DIR, `${appName}.app`);
|
|
422
|
+
return {
|
|
423
|
+
kind,
|
|
424
|
+
appName,
|
|
425
|
+
scheme,
|
|
426
|
+
cliCandidates: [cli, join3(appBundle, "Contents", "Resources", "app", "bin", cli)],
|
|
427
|
+
extensionsDir: join3(homedir2(), homeDir, "extensions"),
|
|
428
|
+
appBundle
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
var IDE_DEFINITIONS = {
|
|
432
|
+
cursor: define({ kind: "cursor", appName: "Cursor", scheme: "cursor", cli: "cursor", homeDir: ".cursor" }),
|
|
433
|
+
vscode: define({ kind: "vscode", appName: "Visual Studio Code", scheme: "vscode", cli: "code", homeDir: ".vscode" }),
|
|
434
|
+
vscode_insiders: define({ kind: "vscode_insiders", appName: "Visual Studio Code - Insiders", scheme: "vscode-insiders", cli: "code-insiders", homeDir: ".vscode-insiders" }),
|
|
435
|
+
vscodium: define({ kind: "vscodium", appName: "VSCodium", scheme: "vscodium", cli: "codium", homeDir: ".vscode-oss" }),
|
|
436
|
+
windsurf: define({ kind: "windsurf", appName: "Windsurf", scheme: "windsurf", cli: "windsurf", homeDir: ".windsurf" }),
|
|
437
|
+
kiro: define({ kind: "kiro", appName: "Kiro", scheme: "kiro", cli: "kiro", homeDir: ".kiro" }),
|
|
438
|
+
trae: define({ kind: "trae", appName: "Trae", scheme: "trae", cli: "trae", homeDir: ".trae" })
|
|
439
|
+
};
|
|
440
|
+
var IDE_KINDS = Object.keys(IDE_DEFINITIONS);
|
|
441
|
+
|
|
442
|
+
// src/ide/is_ide_kind.ts
|
|
443
|
+
function isIdeKind(value) {
|
|
444
|
+
return IDE_KINDS.includes(value);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/ide/is_ide_launcher_installed.ts
|
|
448
|
+
import { existsSync as existsSync3, readdirSync } from "node:fs";
|
|
449
|
+
|
|
450
|
+
// src/ide/extension_files.ts
|
|
451
|
+
var EXTENSION_PUBLISHER = "eventually";
|
|
452
|
+
var EXTENSION_NAME = "dario-launcher";
|
|
453
|
+
var EXTENSION_VERSION = "1.0.0";
|
|
454
|
+
function createExtensionFiles({ binPath }) {
|
|
455
|
+
const packageJson = {
|
|
456
|
+
name: EXTENSION_NAME,
|
|
457
|
+
displayName: "Dario Launcher",
|
|
458
|
+
description: "Opens the Dario terminal game in an integrated terminal (vscode://eventually.dario-launcher/play).",
|
|
459
|
+
version: EXTENSION_VERSION,
|
|
460
|
+
publisher: EXTENSION_PUBLISHER,
|
|
461
|
+
engines: { vscode: "^1.80.0" },
|
|
462
|
+
categories: ["Other"],
|
|
463
|
+
activationEvents: ["onUri", "onCommand:dario.play"],
|
|
464
|
+
main: "./extension.js",
|
|
465
|
+
contributes: { commands: [{ command: "dario.play", title: "Dario: Play" }] }
|
|
466
|
+
};
|
|
467
|
+
const extensionJs = [
|
|
468
|
+
"const vscode = require('vscode');",
|
|
469
|
+
`const BIN = ${JSON.stringify(binPath)};`,
|
|
470
|
+
"let terminal = null;",
|
|
471
|
+
"function play(agent) {",
|
|
472
|
+
" const args = agent ? ` --as ${agent}` : '';",
|
|
473
|
+
" if (terminal === null || terminal.exitStatus !== undefined) terminal = vscode.window.createTerminal({ name: 'Dario' });",
|
|
474
|
+
" terminal.show(true);",
|
|
475
|
+
" terminal.sendText(`'${BIN}' play${args}`);",
|
|
476
|
+
"}",
|
|
477
|
+
"exports.activate = (context) => {",
|
|
478
|
+
" context.subscriptions.push(vscode.window.registerUriHandler({ handleUri(uri) { if (uri.path === '/play') play(new URLSearchParams(uri.query).get('as')); } }));",
|
|
479
|
+
" context.subscriptions.push(vscode.commands.registerCommand('dario.play', () => play(null)));",
|
|
480
|
+
"};",
|
|
481
|
+
"exports.deactivate = () => {};",
|
|
482
|
+
""
|
|
483
|
+
].join("\n");
|
|
484
|
+
const manifest = [
|
|
485
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
486
|
+
'<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">',
|
|
487
|
+
` <Metadata><Identity Language="en-US" Id="${EXTENSION_NAME}" Version="${EXTENSION_VERSION}" Publisher="${EXTENSION_PUBLISHER}"/><DisplayName>Dario Launcher</DisplayName><Description xml:space="preserve">Opens the Dario terminal game in an integrated terminal.</Description><Categories>Other</Categories></Metadata>`,
|
|
488
|
+
' <Installation><InstallationTarget Id="Microsoft.VisualStudio.Code"/></Installation>',
|
|
489
|
+
" <Dependencies/>",
|
|
490
|
+
' <Assets><Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true"/></Assets>',
|
|
491
|
+
"</PackageManifest>",
|
|
492
|
+
""
|
|
493
|
+
].join("\n");
|
|
494
|
+
const contentTypes = '<?xml version="1.0" encoding="utf-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="json" ContentType="application/json"/><Default Extension="vsixmanifest" ContentType="text/xml"/><Default Extension="js" ContentType="application/javascript"/></Types>\n';
|
|
495
|
+
return [
|
|
496
|
+
{ path: "extension.vsixmanifest", content: manifest },
|
|
497
|
+
{ path: "[Content_Types].xml", content: contentTypes },
|
|
498
|
+
{ path: "extension/package.json", content: `${JSON.stringify(packageJson, null, 2)}
|
|
499
|
+
` },
|
|
500
|
+
{ path: "extension/extension.js", content: extensionJs }
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/ide/is_ide_launcher_installed.ts
|
|
505
|
+
function isIdeLauncherInstalled(kind) {
|
|
506
|
+
const dir = IDE_DEFINITIONS[kind].extensionsDir;
|
|
507
|
+
if (!existsSync3(dir)) return false;
|
|
508
|
+
const prefix = `${EXTENSION_PUBLISHER}.${EXTENSION_NAME}-`;
|
|
509
|
+
return readdirSync(dir).some((entry) => entry.startsWith(prefix));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/launcher/terminal_app_bundles.ts
|
|
513
|
+
var TERMINAL_APP_BUNDLES = {
|
|
514
|
+
warp: "/Applications/Warp.app",
|
|
515
|
+
iterm: "/Applications/iTerm.app",
|
|
516
|
+
ghostty: "/Applications/Ghostty.app",
|
|
517
|
+
wezterm: "/Applications/WezTerm.app",
|
|
518
|
+
kitty: "/Applications/kitty.app",
|
|
519
|
+
alacritty: "/Applications/Alacritty.app",
|
|
520
|
+
tabby: "/Applications/Tabby.app",
|
|
521
|
+
rio: "/Applications/Rio.app"
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// src/launcher/detect_terminal.ts
|
|
525
|
+
var TERM_PROGRAM_KINDS = {
|
|
526
|
+
WarpTerminal: "warp",
|
|
527
|
+
"iTerm.app": "iterm",
|
|
528
|
+
Apple_Terminal: "apple_terminal",
|
|
529
|
+
ghostty: "ghostty",
|
|
530
|
+
WezTerm: "wezterm",
|
|
531
|
+
Tabby: "tabby",
|
|
532
|
+
rio: "rio"
|
|
533
|
+
};
|
|
534
|
+
var INSTALL_PREFERENCE = ["warp", "iterm", "ghostty", "wezterm", "kitty", "alacritty", "tabby", "rio"];
|
|
535
|
+
function isBundleInstalled(kind) {
|
|
536
|
+
if (kind === "apple_terminal") return true;
|
|
537
|
+
if (isIdeKind(kind)) return isIdeLauncherInstalled(kind);
|
|
538
|
+
return existsSync4(TERMINAL_APP_BUNDLES[kind]);
|
|
539
|
+
}
|
|
540
|
+
function detectTerminal({ env, preferred, isInstalled = isBundleInstalled }) {
|
|
541
|
+
if (preferred !== "auto" && isInstalled(preferred)) return preferred;
|
|
542
|
+
const fromProgram = TERM_PROGRAM_KINDS[env.TERM_PROGRAM ?? ""];
|
|
543
|
+
if (fromProgram !== void 0) return fromProgram;
|
|
544
|
+
if (env.KITTY_WINDOW_ID !== void 0) return "kitty";
|
|
545
|
+
if (env.ALACRITTY_WINDOW_ID !== void 0) return "alacritty";
|
|
546
|
+
return INSTALL_PREFERENCE.find(isInstalled) ?? "apple_terminal";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/launcher/sleep_sync.ts
|
|
550
|
+
function sleepSync(milliseconds) {
|
|
551
|
+
if (milliseconds <= 0) return;
|
|
552
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/claude/describe_tool_use.ts
|
|
556
|
+
import { basename as basename2 } from "node:path";
|
|
557
|
+
var MAX_DETAIL = 34;
|
|
558
|
+
var SEPARATOR = " \xB7 ";
|
|
559
|
+
function firstString(input, keys) {
|
|
560
|
+
const found = keys.map((key) => input[key]).find((value) => typeof value === "string" && value.length > 0);
|
|
561
|
+
return typeof found === "string" ? found : "";
|
|
562
|
+
}
|
|
563
|
+
function shorten(text) {
|
|
564
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
565
|
+
return oneLine.length > MAX_DETAIL ? `${oneLine.slice(0, MAX_DETAIL - 1)}\u2026` : oneLine;
|
|
566
|
+
}
|
|
567
|
+
function describeToolUse({ toolName, toolInput }) {
|
|
568
|
+
const path = firstString(toolInput, ["file_path", "notebook_path", "path"]);
|
|
569
|
+
const detail = path.length > 0 ? basename2(path) : firstString(toolInput, ["command", "description", "pattern", "query", "url", "prompt", "skill"]);
|
|
570
|
+
return detail.length === 0 ? toolName : `${toolName}${SEPARATOR}${shorten(detail)}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/claude/claude_hook_service.ts
|
|
574
|
+
var ATTENTION_NOTIFICATIONS = /* @__PURE__ */ new Set(["permission_prompt", "idle_prompt", "elicitation_dialog"]);
|
|
575
|
+
var SUPPORTED_PLATFORM = "darwin";
|
|
576
|
+
var NO_ORIGIN = { bundleId: "", name: "" };
|
|
577
|
+
var DEFAULT_FOCUS_DELAY_MS = 350;
|
|
578
|
+
var DEFAULT_AGENT = "claude";
|
|
579
|
+
var NOTHING_DONE = { statusWritten: false, launched: false, shown: false, focusReturned: false };
|
|
580
|
+
var ClaudeHookService = class {
|
|
581
|
+
statusWriter;
|
|
582
|
+
configStore;
|
|
583
|
+
lock;
|
|
584
|
+
launcher;
|
|
585
|
+
focus;
|
|
586
|
+
platform;
|
|
587
|
+
env;
|
|
588
|
+
focusDelayMs;
|
|
589
|
+
constructor({ statusWriter, configStore, lock, launcher, focus, platform = process.platform, env = process.env, focusDelayMs = DEFAULT_FOCUS_DELAY_MS }) {
|
|
590
|
+
this.statusWriter = statusWriter;
|
|
591
|
+
this.configStore = configStore;
|
|
592
|
+
this.lock = lock;
|
|
593
|
+
this.launcher = launcher;
|
|
594
|
+
this.focus = focus;
|
|
595
|
+
this.platform = platform;
|
|
596
|
+
this.env = env;
|
|
597
|
+
this.focusDelayMs = focusDelayMs;
|
|
598
|
+
}
|
|
599
|
+
handle({ event, payload, agent = DEFAULT_AGENT }) {
|
|
600
|
+
if (event === "user_prompt_submit") return this.handlePromptSubmit({ payload, agent });
|
|
601
|
+
if (event === "pre_tool_use") return this.handleActivity({ message: describeToolUse({ toolName: payload.tool_name ?? payload.toolName ?? "tool", toolInput: payload.tool_input ?? payload.toolInput ?? {} }), agent });
|
|
602
|
+
if (event === "post_tool_use") return this.handleActivity({ message: "", agent });
|
|
603
|
+
if (event === "stop") return this.handleUserNeeded({ status: "done", payload, agent });
|
|
604
|
+
if (event === "attention") return this.handleUserNeeded({ status: "attention", payload, agent });
|
|
605
|
+
const isAttention = ATTENTION_NOTIFICATIONS.has(payload.notification_type ?? payload.notificationType ?? "");
|
|
606
|
+
return isAttention ? this.handleUserNeeded({ status: "attention", payload, agent }) : NOTHING_DONE;
|
|
607
|
+
}
|
|
608
|
+
get isMac() {
|
|
609
|
+
return this.platform === SUPPORTED_PLATFORM;
|
|
610
|
+
}
|
|
611
|
+
/** New prompt: remember where it came from, then open the game (as the prompting agent) or bring the running one forward. */
|
|
612
|
+
handlePromptSubmit({ payload, agent }) {
|
|
613
|
+
const origin = this.isMac ? this.focus.capture() : NO_ORIGIN;
|
|
614
|
+
this.statusWriter.write({ status: "working", cwd: payload.cwd ?? "", message: payload.message ?? "", origin, agent });
|
|
615
|
+
const config = this.configStore.load();
|
|
616
|
+
if (!config.autoLaunch || !this.isMac) return { ...NOTHING_DONE, statusWritten: true };
|
|
617
|
+
if (!this.lock.isHeld()) {
|
|
618
|
+
this.launcher.launch({ preferred: config.terminal, agent, lock: this.lock });
|
|
619
|
+
return { ...NOTHING_DONE, statusWritten: true, launched: true };
|
|
620
|
+
}
|
|
621
|
+
this.focus.showTerminal({ kind: detectTerminal({ env: this.env, preferred: config.terminal }), current: origin });
|
|
622
|
+
return { ...NOTHING_DONE, statusWritten: true, shown: true };
|
|
623
|
+
}
|
|
624
|
+
/** A tool started or finished: refresh the "working" line without touching launch or focus. */
|
|
625
|
+
handleActivity({ message, agent }) {
|
|
626
|
+
const previous = this.statusWriter.read();
|
|
627
|
+
this.statusWriter.write({ status: "working", cwd: previous?.cwd ?? "", message, origin: previous?.origin ?? NO_ORIGIN, agent });
|
|
628
|
+
return { ...NOTHING_DONE, statusWritten: true };
|
|
629
|
+
}
|
|
630
|
+
/** The agent is done or blocked: keep the origin app on record, then bring it back if the game is up. */
|
|
631
|
+
handleUserNeeded({ status, payload, agent }) {
|
|
632
|
+
const origin = this.statusWriter.read()?.origin ?? NO_ORIGIN;
|
|
633
|
+
this.statusWriter.write({ status, cwd: payload.cwd ?? "", message: payload.message ?? "", origin, agent });
|
|
634
|
+
const canReturn = this.configStore.load().returnFocus && this.isMac && this.lock.isHeld();
|
|
635
|
+
if (!canReturn) return { ...NOTHING_DONE, statusWritten: true };
|
|
636
|
+
sleepSync(this.focusDelayMs);
|
|
637
|
+
this.focus.restore(origin);
|
|
638
|
+
return { ...NOTHING_DONE, statusWritten: true, focusReturned: true };
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// src/claude/claude_status_writer.ts
|
|
643
|
+
var NO_ORIGIN2 = { bundleId: "", name: "" };
|
|
644
|
+
var DEFAULT_AGENT2 = "claude";
|
|
645
|
+
var ClaudeStatusWriter = class {
|
|
646
|
+
file;
|
|
647
|
+
constructor({ path }) {
|
|
648
|
+
this.file = new JsonFile({ path, fallback: null });
|
|
649
|
+
}
|
|
650
|
+
read() {
|
|
651
|
+
const record = this.file.read();
|
|
652
|
+
return record === null ? null : { ...record, origin: record.origin ?? NO_ORIGIN2, agent: record.agent ?? DEFAULT_AGENT2 };
|
|
653
|
+
}
|
|
654
|
+
write({ status, cwd = "", message = "", origin = NO_ORIGIN2, agent = DEFAULT_AGENT2 }) {
|
|
655
|
+
const record = { status, cwd, message, origin, agent, updatedAt: Date.now() };
|
|
656
|
+
this.file.write(record);
|
|
657
|
+
return record;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// src/launcher/app_focus.ts
|
|
662
|
+
import { spawnSync } from "node:child_process";
|
|
663
|
+
|
|
664
|
+
// src/launcher/terminal_app_names.ts
|
|
665
|
+
var TERMINAL_APP_NAMES = {
|
|
666
|
+
warp: "Warp",
|
|
667
|
+
iterm: "iTerm",
|
|
668
|
+
apple_terminal: "Terminal",
|
|
669
|
+
ghostty: "Ghostty",
|
|
670
|
+
wezterm: "WezTerm",
|
|
671
|
+
kitty: "kitty",
|
|
672
|
+
alacritty: "Alacritty",
|
|
673
|
+
tabby: "Tabby",
|
|
674
|
+
rio: "Rio",
|
|
675
|
+
cursor: "Cursor",
|
|
676
|
+
vscode: "Visual Studio Code",
|
|
677
|
+
vscode_insiders: "Visual Studio Code - Insiders",
|
|
678
|
+
vscodium: "VSCodium",
|
|
679
|
+
windsurf: "Windsurf",
|
|
680
|
+
kiro: "Kiro",
|
|
681
|
+
trae: "Trae"
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/launcher/app_focus.ts
|
|
685
|
+
var SCRIPT_TIMEOUT_MS = 3e3;
|
|
686
|
+
var WARP_BUNDLE_PREFIX = "dev.warp.";
|
|
687
|
+
var NO_TARGET = { bundleId: "", name: "" };
|
|
688
|
+
var CAPTURE_SCRIPT = [
|
|
689
|
+
'tell application "System Events"',
|
|
690
|
+
" set frontApp to first application process whose frontmost is true",
|
|
691
|
+
" return (bundle identifier of frontApp) & linefeed & (name of frontApp)",
|
|
692
|
+
"end tell"
|
|
693
|
+
].join("\n");
|
|
694
|
+
var PREVIOUS_TAB_SCRIPT = 'tell application "System Events" to keystroke "[" using {command down, shift down}';
|
|
695
|
+
var NEXT_TAB_SCRIPT = 'tell application "System Events" to keystroke "]" using {command down, shift down}';
|
|
696
|
+
function runOsascript(script) {
|
|
697
|
+
const result = spawnSync("osascript", ["-e", script], { encoding: "utf8", timeout: SCRIPT_TIMEOUT_MS });
|
|
698
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
699
|
+
}
|
|
700
|
+
function escapeAppleScript(text) {
|
|
701
|
+
return text.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
702
|
+
}
|
|
703
|
+
function isWarp(target) {
|
|
704
|
+
return target.bundleId.startsWith(WARP_BUNDLE_PREFIX);
|
|
705
|
+
}
|
|
706
|
+
var AppFocus = class {
|
|
707
|
+
runScript;
|
|
708
|
+
constructor({ runScript = runOsascript } = {}) {
|
|
709
|
+
this.runScript = runScript;
|
|
710
|
+
}
|
|
711
|
+
capture() {
|
|
712
|
+
const [bundleId = "", name = ""] = this.runScript(CAPTURE_SCRIPT).split("\n");
|
|
713
|
+
if (bundleId.length === 0) return NO_TARGET;
|
|
714
|
+
return { bundleId, name };
|
|
715
|
+
}
|
|
716
|
+
/** Back to where the prompt was typed; from Warp that means the tab before the game tab. */
|
|
717
|
+
restore(target) {
|
|
718
|
+
if (target.bundleId.length === 0) return;
|
|
719
|
+
this.runScript(`tell application id "${escapeAppleScript(target.bundleId)}" to activate`);
|
|
720
|
+
if (isWarp(target)) this.runScript(PREVIOUS_TAB_SCRIPT);
|
|
721
|
+
}
|
|
722
|
+
/** Bring the running game forward; when the user is already inside Warp, step to the game tab. */
|
|
723
|
+
showTerminal({ kind, current }) {
|
|
724
|
+
this.runScript(`tell application "${TERMINAL_APP_NAMES[kind]}" to activate`);
|
|
725
|
+
if (kind === "warp" && isWarp(current)) this.runScript(NEXT_TAB_SCRIPT);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// src/launcher/process_lock.ts
|
|
730
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
731
|
+
import { dirname as dirname2 } from "node:path";
|
|
732
|
+
var PROBE_SIGNAL = 0;
|
|
733
|
+
var ProcessLock = class {
|
|
734
|
+
path;
|
|
735
|
+
constructor({ path }) {
|
|
736
|
+
this.path = path;
|
|
737
|
+
}
|
|
738
|
+
readPid() {
|
|
739
|
+
if (!existsSync5(this.path)) return null;
|
|
740
|
+
const pid = Number.parseInt(readFileSync2(this.path, "utf8").trim(), 10);
|
|
741
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
742
|
+
}
|
|
743
|
+
isHeld() {
|
|
744
|
+
const pid = this.readPid();
|
|
745
|
+
if (pid === null) return false;
|
|
746
|
+
return isProcessAlive(pid);
|
|
747
|
+
}
|
|
748
|
+
acquire() {
|
|
749
|
+
if (this.isHeld()) return false;
|
|
750
|
+
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
751
|
+
writeFileSync2(this.path, `${process.pid}
|
|
752
|
+
`);
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
release() {
|
|
756
|
+
if (this.readPid() !== process.pid) return;
|
|
757
|
+
unlinkSync(this.path);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
function isProcessAlive(pid) {
|
|
761
|
+
try {
|
|
762
|
+
process.kill(pid, PROBE_SIGNAL);
|
|
763
|
+
return true;
|
|
764
|
+
} catch {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/launcher/terminal_launcher.ts
|
|
770
|
+
import { spawn } from "node:child_process";
|
|
771
|
+
|
|
772
|
+
// src/launcher/quote_for_shell.ts
|
|
773
|
+
function quoteForShell(parts) {
|
|
774
|
+
return parts.map((part) => /^[A-Za-z0-9_./=:-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`).join(" ");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/launcher/warp_tab_config.ts
|
|
778
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
779
|
+
import { dirname as dirname3 } from "node:path";
|
|
780
|
+
var CONFIG_NAME = "dario";
|
|
781
|
+
var EXIT_COMMAND = "exit";
|
|
782
|
+
var WarpTabConfig = class {
|
|
783
|
+
path;
|
|
784
|
+
command;
|
|
785
|
+
cwd;
|
|
786
|
+
constructor({ path, command, cwd }) {
|
|
787
|
+
this.path = path;
|
|
788
|
+
this.command = command;
|
|
789
|
+
this.cwd = cwd;
|
|
790
|
+
}
|
|
791
|
+
getUri() {
|
|
792
|
+
return `warp://tab_config/${CONFIG_NAME}`;
|
|
793
|
+
}
|
|
794
|
+
write() {
|
|
795
|
+
mkdirSync3(dirname3(this.path), { recursive: true });
|
|
796
|
+
writeFileSync3(this.path, this.render());
|
|
797
|
+
}
|
|
798
|
+
render() {
|
|
799
|
+
return [
|
|
800
|
+
'name = "Dario"',
|
|
801
|
+
'title = "Dario"',
|
|
802
|
+
'color = "yellow"',
|
|
803
|
+
"",
|
|
804
|
+
"[[panes]]",
|
|
805
|
+
'id = "main"',
|
|
806
|
+
'type = "terminal"',
|
|
807
|
+
`directory = ${JSON.stringify(this.cwd)}`,
|
|
808
|
+
`commands = [${JSON.stringify(this.command)}, ${JSON.stringify(EXIT_COMMAND)}]`,
|
|
809
|
+
""
|
|
810
|
+
].join("\n");
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// src/launcher/build_launch_spec.ts
|
|
815
|
+
function buildIdeSpec({ kind, agent }) {
|
|
816
|
+
const query = agent === null ? "" : `?as=${agent}`;
|
|
817
|
+
return { executable: "open", args: [`${IDE_DEFINITIONS[kind].scheme}://${EXTENSION_PUBLISHER}.${EXTENSION_NAME}/play${query}`] };
|
|
818
|
+
}
|
|
819
|
+
var APPLE_TERMINAL_COLUMNS = 140;
|
|
820
|
+
var APPLE_TERMINAL_ROWS = 42;
|
|
821
|
+
function escapeAppleScript2(text) {
|
|
822
|
+
return text.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
823
|
+
}
|
|
824
|
+
function buildItermScript(command) {
|
|
825
|
+
const quoted = escapeAppleScript2(command);
|
|
826
|
+
return [
|
|
827
|
+
'tell application "iTerm"',
|
|
828
|
+
" activate",
|
|
829
|
+
" if (count of windows) = 0 then",
|
|
830
|
+
` create window with default profile command "${quoted}"`,
|
|
831
|
+
" else",
|
|
832
|
+
` tell current window to create tab with default profile command "${quoted}"`,
|
|
833
|
+
" end if",
|
|
834
|
+
"end tell"
|
|
835
|
+
].join("\n");
|
|
836
|
+
}
|
|
837
|
+
function buildAppleTerminalScript(command) {
|
|
838
|
+
const quoted = escapeAppleScript2(`${command}; exit`);
|
|
839
|
+
return [
|
|
840
|
+
'tell application "Terminal"',
|
|
841
|
+
" activate",
|
|
842
|
+
` set gameTab to do script "${quoted}"`,
|
|
843
|
+
` set number of columns of gameTab to ${APPLE_TERMINAL_COLUMNS}`,
|
|
844
|
+
` set number of rows of gameTab to ${APPLE_TERMINAL_ROWS}`,
|
|
845
|
+
"end tell"
|
|
846
|
+
].join("\n");
|
|
847
|
+
}
|
|
848
|
+
function buildWarpSpec(command) {
|
|
849
|
+
const config = new WarpTabConfig({ path: DARIO_PATHS.warpTabConfig, command, cwd: DARIO_PATHS.rootDir });
|
|
850
|
+
config.write();
|
|
851
|
+
return { executable: "open", args: [config.getUri()] };
|
|
852
|
+
}
|
|
853
|
+
function buildOpenSpec({ kind, flags, commandParts }) {
|
|
854
|
+
return { executable: "open", args: ["-na", TERMINAL_APP_NAMES[kind], "--args", ...flags, ...commandParts] };
|
|
855
|
+
}
|
|
856
|
+
function buildLaunchSpec({ kind, commandParts, agent = null }) {
|
|
857
|
+
if (isIdeKind(kind)) return buildIdeSpec({ kind, agent });
|
|
858
|
+
const command = quoteForShell(commandParts);
|
|
859
|
+
const strategies = {
|
|
860
|
+
warp: () => buildWarpSpec(command),
|
|
861
|
+
iterm: () => ({ executable: "osascript", args: ["-e", buildItermScript(command)] }),
|
|
862
|
+
apple_terminal: () => ({ executable: "osascript", args: ["-e", buildAppleTerminalScript(command)] }),
|
|
863
|
+
ghostty: () => buildOpenSpec({ kind, flags: ["-e"], commandParts }),
|
|
864
|
+
wezterm: () => buildOpenSpec({ kind, flags: ["start", "--"], commandParts }),
|
|
865
|
+
kitty: () => buildOpenSpec({ kind, flags: [], commandParts }),
|
|
866
|
+
alacritty: () => buildOpenSpec({ kind, flags: ["-e"], commandParts }),
|
|
867
|
+
tabby: () => buildOpenSpec({ kind, flags: ["run"], commandParts }),
|
|
868
|
+
rio: () => buildOpenSpec({ kind, flags: ["-e"], commandParts })
|
|
869
|
+
};
|
|
870
|
+
return strategies[kind]();
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/launcher/terminal_launcher.ts
|
|
874
|
+
var NODE_FLAGS = ["--no-warnings=ExperimentalWarning"];
|
|
875
|
+
var DEFAULT_IDE_GRACE_MS = 3e3;
|
|
876
|
+
var TerminalLauncher = class {
|
|
877
|
+
run;
|
|
878
|
+
ideGraceMs;
|
|
879
|
+
constructor({ run = runDetached, ideGraceMs = DEFAULT_IDE_GRACE_MS } = {}) {
|
|
880
|
+
this.run = run;
|
|
881
|
+
this.ideGraceMs = ideGraceMs;
|
|
882
|
+
}
|
|
883
|
+
launch({ preferred, agent = null, env = process.env, lock = null }) {
|
|
884
|
+
const kind = detectTerminal({ env, preferred });
|
|
885
|
+
const agentArgs = agent === null ? [] : ["--as", agent];
|
|
886
|
+
const command = [process.execPath, ...NODE_FLAGS, DARIO_PATHS.entryFile, "play", ...agentArgs];
|
|
887
|
+
this.run(buildLaunchSpec({ kind, commandParts: command, agent }));
|
|
888
|
+
if (!isIdeKind(kind) || lock === null) return { kind, command };
|
|
889
|
+
sleepSync(this.ideGraceMs);
|
|
890
|
+
if (lock.isHeld()) return { kind, command };
|
|
891
|
+
const fallback = detectTerminal({ env, preferred: "auto" });
|
|
892
|
+
this.run(buildLaunchSpec({ kind: fallback, commandParts: command, agent }));
|
|
893
|
+
return { kind: fallback, command };
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
function runDetached(spec) {
|
|
897
|
+
const child = spawn(spec.executable, [...spec.args], { detached: true, stdio: "ignore" });
|
|
898
|
+
child.unref();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/cli/read_stdin_json.ts
|
|
902
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
903
|
+
var STDIN_FD = 0;
|
|
904
|
+
function readStdinJson(fallback) {
|
|
905
|
+
if (process.stdin.isTTY) return fallback;
|
|
906
|
+
try {
|
|
907
|
+
const raw = readFileSync3(STDIN_FD, "utf8").trim();
|
|
908
|
+
return raw.length === 0 ? fallback : JSON.parse(raw);
|
|
909
|
+
} catch {
|
|
910
|
+
return fallback;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/cli/commands/hook_command.ts
|
|
915
|
+
var HOOK_EVENTS = ["user_prompt_submit", "stop", "notification", "attention", "pre_tool_use", "post_tool_use"];
|
|
916
|
+
var AGENT_FLAG = "--agent";
|
|
917
|
+
function isHookEvent(value) {
|
|
918
|
+
return HOOK_EVENTS.includes(value);
|
|
919
|
+
}
|
|
920
|
+
function readHookAgent(args) {
|
|
921
|
+
const value = args[args.indexOf(AGENT_FLAG) + 1];
|
|
922
|
+
return args.includes(AGENT_FLAG) && isAgentKind(value) ? value : "claude";
|
|
923
|
+
}
|
|
924
|
+
var HOOK_COMMAND = {
|
|
925
|
+
name: "hook",
|
|
926
|
+
aliases: [],
|
|
927
|
+
usage: `dario hook <${HOOK_EVENTS.join("|")}> [--agent claude|codex|grok]`,
|
|
928
|
+
description: "Internal entry point called by the Claude Code / Codex / Grok hook scripts (hook JSON on stdin).",
|
|
929
|
+
async execute(args) {
|
|
930
|
+
const event = args[0] ?? "";
|
|
931
|
+
if (!isHookEvent(event)) {
|
|
932
|
+
console.error(`Unknown hook event "${event}". Expected one of: ${HOOK_EVENTS.join(", ")}`);
|
|
933
|
+
return 1;
|
|
934
|
+
}
|
|
935
|
+
const payload = readStdinJson({});
|
|
936
|
+
const service = new ClaudeHookService({
|
|
937
|
+
statusWriter: new ClaudeStatusWriter({ path: DARIO_PATHS.claudeStatusFile }),
|
|
938
|
+
configStore: new ConfigStore({ path: DARIO_PATHS.configFile }),
|
|
939
|
+
lock: new ProcessLock({ path: DARIO_PATHS.pidFile }),
|
|
940
|
+
launcher: new TerminalLauncher(),
|
|
941
|
+
focus: new AppFocus()
|
|
942
|
+
});
|
|
943
|
+
service.handle({ event, payload, agent: readHookAgent(args) });
|
|
944
|
+
return 0;
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
// src/cli/commands/ide_command.ts
|
|
949
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
950
|
+
|
|
951
|
+
// src/ide/ide_installer.ts
|
|
952
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
953
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
954
|
+
|
|
955
|
+
// src/ide/build_vsix.ts
|
|
956
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
957
|
+
import { mkdirSync as mkdirSync4, mkdtempSync, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
958
|
+
import { tmpdir } from "node:os";
|
|
959
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
960
|
+
function buildVsix({ binPath, outputPath }) {
|
|
961
|
+
const stage = mkdtempSync(join4(tmpdir(), "dario-vsix-"));
|
|
962
|
+
createExtensionFiles({ binPath }).forEach((file) => {
|
|
963
|
+
mkdirSync4(dirname4(join4(stage, file.path)), { recursive: true });
|
|
964
|
+
writeFileSync4(join4(stage, file.path), file.content);
|
|
965
|
+
});
|
|
966
|
+
mkdirSync4(dirname4(outputPath), { recursive: true });
|
|
967
|
+
rmSync(outputPath, { force: true });
|
|
968
|
+
const result = spawnSync2("zip", ["-q", "-r", outputPath, "."], { cwd: stage, encoding: "utf8" });
|
|
969
|
+
rmSync(stage, { recursive: true, force: true });
|
|
970
|
+
if (result.status !== 0) throw new Error(`zip failed: ${result.stderr}`);
|
|
971
|
+
return outputPath;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/ide/ide_installer.ts
|
|
975
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
976
|
+
function findCli(kind) {
|
|
977
|
+
return IDE_DEFINITIONS[kind].cliCandidates.find((candidate) => candidate.includes("/") ? existsSync6(candidate) : spawnSync3("which", [candidate]).status === 0) ?? null;
|
|
978
|
+
}
|
|
979
|
+
var IdeInstaller = class {
|
|
980
|
+
isInstalled(kind) {
|
|
981
|
+
return isIdeLauncherInstalled(kind);
|
|
982
|
+
}
|
|
983
|
+
install(kind) {
|
|
984
|
+
const cli = findCli(kind);
|
|
985
|
+
if (cli === null) throw new Error(`${IDE_DEFINITIONS[kind].appName} command line tool not found; install it from the IDE's command palette ("Shell Command: Install ... command").`);
|
|
986
|
+
const vsix = buildVsix({ binPath: DARIO_PATHS.binFile, outputPath: DARIO_PATHS.vsixFile });
|
|
987
|
+
const result = spawnSync3(cli, ["--install-extension", vsix, "--force"], { encoding: "utf8", timeout: INSTALL_TIMEOUT_MS });
|
|
988
|
+
if (result.status !== 0) throw new Error(`${cli} --install-extension failed: ${result.stderr || result.stdout}`);
|
|
989
|
+
return { vsix, cli };
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// src/cli/commands/ide_command.ts
|
|
994
|
+
var KIND_WIDTH = 17;
|
|
995
|
+
function describeIde(kind, installer) {
|
|
996
|
+
const definition = IDE_DEFINITIONS[kind];
|
|
997
|
+
const state = installer.isInstalled(kind) ? "launcher installed" : existsSync7(definition.appBundle) ? "not installed" : "app not found";
|
|
998
|
+
return ` ${kind.padEnd(KIND_WIDTH)} ${definition.appName.padEnd(30)} ${state}`;
|
|
999
|
+
}
|
|
1000
|
+
var IDE_COMMAND = {
|
|
1001
|
+
name: "ide",
|
|
1002
|
+
aliases: ["editor"],
|
|
1003
|
+
usage: `dario ide <${IDE_KINDS.join("|")}>`,
|
|
1004
|
+
description: "Install the launcher extension into a VS Code-style IDE and open the game in its integrated terminal from now on.",
|
|
1005
|
+
async execute(args) {
|
|
1006
|
+
const installer = new IdeInstaller();
|
|
1007
|
+
const value = (args[0] ?? "").toLowerCase().replaceAll("-", "_");
|
|
1008
|
+
if (value.length === 0) {
|
|
1009
|
+
IDE_KINDS.forEach((kind) => console.log(describeIde(kind, installer)));
|
|
1010
|
+
return 0;
|
|
1011
|
+
}
|
|
1012
|
+
if (!isIdeKind(value)) {
|
|
1013
|
+
console.error(`Unknown IDE "${value}". Options: ${IDE_KINDS.join(", ")}`);
|
|
1014
|
+
return 1;
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const report = installer.install(value);
|
|
1018
|
+
new ConfigStore({ path: DARIO_PATHS.configFile }).update({ terminal: value });
|
|
1019
|
+
console.log(`Installed ${report.vsix} into ${IDE_DEFINITIONS[value].appName} via ${report.cli}.
|
|
1020
|
+
Terminal preference is now "${value}": the game opens in ${IDE_DEFINITIONS[value].appName}'s integrated terminal.`);
|
|
1021
|
+
return 0;
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1024
|
+
return 1;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
// src/claude/claude_hooks_installer.ts
|
|
1030
|
+
var CLAUDE_HOOK_BINDINGS = {
|
|
1031
|
+
UserPromptSubmit: { script: "user_prompt_submit.sh" },
|
|
1032
|
+
Stop: { script: "stop.sh" },
|
|
1033
|
+
Notification: { script: "notification.sh" },
|
|
1034
|
+
PreToolUse: { script: "pre_tool_use.sh" },
|
|
1035
|
+
PostToolUse: { script: "post_tool_use.sh" }
|
|
1036
|
+
};
|
|
1037
|
+
var ClaudeHooksInstaller = class extends HookSettingsInstaller {
|
|
1038
|
+
constructor({ settingsPath, hooksDir }) {
|
|
1039
|
+
super({ settingsPath, hooksDir, bindings: CLAUDE_HOOK_BINDINGS });
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
// src/install/symlink_installer.ts
|
|
1044
|
+
import { existsSync as existsSync8, lstatSync, mkdirSync as mkdirSync5, readlinkSync, symlinkSync, unlinkSync as unlinkSync2 } from "node:fs";
|
|
1045
|
+
import { dirname as dirname5 } from "node:path";
|
|
1046
|
+
var SymlinkInstaller = class {
|
|
1047
|
+
install({ linkPath, target }) {
|
|
1048
|
+
if (this.pointsTo({ linkPath, target })) return "already_linked";
|
|
1049
|
+
if (this.isSymlink(linkPath)) unlinkSync2(linkPath);
|
|
1050
|
+
else if (existsSync8(linkPath)) return "skipped_existing_path";
|
|
1051
|
+
mkdirSync5(dirname5(linkPath), { recursive: true });
|
|
1052
|
+
symlinkSync(target, linkPath);
|
|
1053
|
+
return "created";
|
|
1054
|
+
}
|
|
1055
|
+
uninstall({ linkPath, target }) {
|
|
1056
|
+
if (!this.pointsTo({ linkPath, target })) return "absent";
|
|
1057
|
+
unlinkSync2(linkPath);
|
|
1058
|
+
return "removed";
|
|
1059
|
+
}
|
|
1060
|
+
isSymlink(path) {
|
|
1061
|
+
try {
|
|
1062
|
+
return lstatSync(path).isSymbolicLink();
|
|
1063
|
+
} catch {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
pointsTo({ linkPath, target }) {
|
|
1068
|
+
if (!this.isSymlink(linkPath)) return false;
|
|
1069
|
+
return readlinkSync(linkPath) === target;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
// src/install/dario_installer.ts
|
|
1074
|
+
var DarioInstaller = class {
|
|
1075
|
+
hooks;
|
|
1076
|
+
agents = new AgentHooksInstaller();
|
|
1077
|
+
symlinks = new SymlinkInstaller();
|
|
1078
|
+
constructor() {
|
|
1079
|
+
this.hooks = new ClaudeHooksInstaller({ settingsPath: DARIO_PATHS.claudeSettingsFile, hooksDir: DARIO_PATHS.hooksDir });
|
|
1080
|
+
}
|
|
1081
|
+
isInstalled() {
|
|
1082
|
+
return this.hooks.isInstalled();
|
|
1083
|
+
}
|
|
1084
|
+
install() {
|
|
1085
|
+
this.hooks.install();
|
|
1086
|
+
return {
|
|
1087
|
+
hooks: "installed",
|
|
1088
|
+
agents: this.agents.installAvailable(),
|
|
1089
|
+
skill: this.symlinks.install({ linkPath: DARIO_PATHS.claudeSkillLink, target: DARIO_PATHS.skillDir }),
|
|
1090
|
+
bin: this.symlinks.install({ linkPath: DARIO_PATHS.localBinLink, target: DARIO_PATHS.binFile })
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
uninstall() {
|
|
1094
|
+
this.hooks.uninstall();
|
|
1095
|
+
return {
|
|
1096
|
+
hooks: "removed",
|
|
1097
|
+
agents: this.agents.uninstallAll(),
|
|
1098
|
+
skill: this.symlinks.uninstall({ linkPath: DARIO_PATHS.claudeSkillLink, target: DARIO_PATHS.skillDir }),
|
|
1099
|
+
bin: this.symlinks.uninstall({ linkPath: DARIO_PATHS.localBinLink, target: DARIO_PATHS.binFile })
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// src/install/relocate_npx_install.ts
|
|
1105
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
1106
|
+
import { cpSync, mkdirSync as mkdirSync6, rmSync as rmSync2 } from "node:fs";
|
|
1107
|
+
import { homedir as homedir3 } from "node:os";
|
|
1108
|
+
import { join as join5 } from "node:path";
|
|
1109
|
+
var NPX_CACHE_MARKER = "/_npx/";
|
|
1110
|
+
var STABLE_HOME = join5(homedir3(), ".dario", "app");
|
|
1111
|
+
function isNpxCacheInstall(rootDir = DARIO_PATHS.rootDir) {
|
|
1112
|
+
return rootDir.includes(NPX_CACHE_MARKER);
|
|
1113
|
+
}
|
|
1114
|
+
function relocateNpxInstall({ args }) {
|
|
1115
|
+
const staging = `${STABLE_HOME}.new`;
|
|
1116
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
1117
|
+
mkdirSync6(staging, { recursive: true });
|
|
1118
|
+
cpSync(DARIO_PATHS.rootDir, staging, { recursive: true, dereference: true });
|
|
1119
|
+
rmSync2(STABLE_HOME, { recursive: true, force: true });
|
|
1120
|
+
cpSync(staging, STABLE_HOME, { recursive: true });
|
|
1121
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
1122
|
+
console.log(`Copied Dario to ${STABLE_HOME} (npx runs from a cache that gets pruned).`);
|
|
1123
|
+
const result = spawnSync4(join5(STABLE_HOME, "bin", "dario"), ["install", ...args], { stdio: "inherit" });
|
|
1124
|
+
return result.status ?? 1;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/player/ask_launch_target.ts
|
|
1128
|
+
import { createInterface } from "node:readline/promises";
|
|
1129
|
+
|
|
1130
|
+
// src/launcher/launch_targets.ts
|
|
1131
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
1132
|
+
var NEW_TAB_TERMINALS = /* @__PURE__ */ new Set(["warp", "iterm"]);
|
|
1133
|
+
var IDE_HOW = "integrated terminal";
|
|
1134
|
+
function describeHow(kind, isIde) {
|
|
1135
|
+
if (isIde) return IDE_HOW;
|
|
1136
|
+
return NEW_TAB_TERMINALS.has(kind) ? "new tab" : "new window";
|
|
1137
|
+
}
|
|
1138
|
+
function listLaunchTargets({ env = process.env } = {}) {
|
|
1139
|
+
const detected = detectTerminal({ env, preferred: "auto" });
|
|
1140
|
+
const terminals = [...INSTALL_PREFERENCE.filter(isBundleInstalled), "apple_terminal"];
|
|
1141
|
+
const ides = IDE_KINDS.filter((kind) => existsSync9(IDE_DEFINITIONS[kind].appBundle));
|
|
1142
|
+
const describe = (kind, isIde, needsExtension) => ({
|
|
1143
|
+
kind,
|
|
1144
|
+
label: `${TERMINAL_APP_NAMES[kind]} (${describeHow(kind, isIde)}${needsExtension ? ", installs a tiny extension" : ""})`,
|
|
1145
|
+
isDetected: kind === detected,
|
|
1146
|
+
needsExtension
|
|
1147
|
+
});
|
|
1148
|
+
return [...terminals.map((kind) => describe(kind, false, false)), ...ides.map((kind) => describe(kind, true, !isIdeLauncherInstalled(kind)))];
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/render/palette.ts
|
|
1152
|
+
var PALETTE = {
|
|
1153
|
+
claudeOrange: { r: 217, g: 119, b: 87 },
|
|
1154
|
+
mascotDead: { r: 120, g: 120, b: 125 },
|
|
1155
|
+
token: { r: 245, g: 196, b: 66 },
|
|
1156
|
+
tokenShine: { r: 255, g: 240, b: 180 },
|
|
1157
|
+
tokenDark: { r: 190, g: 140, b: 30 },
|
|
1158
|
+
bug: { r: 226, g: 75, b: 74 },
|
|
1159
|
+
bugDark: { r: 120, g: 30, b: 30 },
|
|
1160
|
+
wall: { r: 150, g: 150, b: 160 },
|
|
1161
|
+
wallMortar: { r: 85, g: 85, b: 95 },
|
|
1162
|
+
ghost: { r: 168, g: 120, b: 230 },
|
|
1163
|
+
ghostEye: { r: 245, g: 245, b: 255 },
|
|
1164
|
+
ground: { r: 110, g: 110, b: 120 },
|
|
1165
|
+
speck: { r: 65, g: 65, b: 75 },
|
|
1166
|
+
brick: { r: 158, g: 78, b: 46 },
|
|
1167
|
+
brickMortar: { r: 74, g: 32, b: 22 },
|
|
1168
|
+
blockYellow: { r: 245, g: 196, b: 66 },
|
|
1169
|
+
blockYellowDim: { r: 200, g: 150, b: 40 },
|
|
1170
|
+
blockYellowBright: { r: 255, g: 230, b: 150 },
|
|
1171
|
+
blockShadow: { r: 176, g: 112, b: 28 },
|
|
1172
|
+
blockUsed: { r: 140, g: 92, b: 60 },
|
|
1173
|
+
blockUsedEdge: { r: 82, g: 48, b: 30 },
|
|
1174
|
+
pipeGreen: { r: 72, g: 180, b: 72 },
|
|
1175
|
+
pipeDark: { r: 28, g: 96, b: 32 },
|
|
1176
|
+
pipeLight: { r: 150, g: 230, b: 150 },
|
|
1177
|
+
cloud: { r: 236, g: 238, b: 246 },
|
|
1178
|
+
cloudShade: { r: 168, g: 174, b: 192 },
|
|
1179
|
+
hill: { r: 60, g: 150, b: 70 },
|
|
1180
|
+
hillDark: { r: 34, g: 98, b: 46 },
|
|
1181
|
+
mushroomRed: { r: 226, g: 60, b: 60 },
|
|
1182
|
+
mushroomStem: { r: 245, g: 222, b: 184 },
|
|
1183
|
+
heartLight: { r: 255, g: 160, b: 170 },
|
|
1184
|
+
titlePanel: { r: 200, g: 76, b: 12 },
|
|
1185
|
+
titleInk: { r: 252, g: 188, b: 176 },
|
|
1186
|
+
titleShadow: { r: 24, g: 12, b: 8 },
|
|
1187
|
+
text: { r: 205, g: 205, b: 215 },
|
|
1188
|
+
textDim: { r: 120, g: 120, b: 130 },
|
|
1189
|
+
success: { r: 90, g: 200, b: 120 },
|
|
1190
|
+
warning: { r: 245, g: 196, b: 66 },
|
|
1191
|
+
working: { r: 120, g: 170, b: 255 },
|
|
1192
|
+
danger: { r: 226, g: 75, b: 74 }
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// src/player/ask_launch_target.ts
|
|
1196
|
+
function createTerminalQuestion() {
|
|
1197
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
1198
|
+
return { ask: (prompt) => readline.question(prompt), close: () => readline.close() };
|
|
1199
|
+
}
|
|
1200
|
+
function renderMenu(targets) {
|
|
1201
|
+
const lines = targets.map((target, i) => {
|
|
1202
|
+
const marker = target.isDetected ? ` ${ANSI.fg(PALETTE.success)}\u2190 detected${ANSI.reset}` : "";
|
|
1203
|
+
return ` ${ANSI.bold}${i + 1})${ANSI.reset} ${target.label}${marker}`;
|
|
1204
|
+
});
|
|
1205
|
+
return [`${ANSI.fg(PALETTE.claudeOrange)}${ANSI.bold}Where should Dario open when you send a prompt?${ANSI.reset}`, ...lines].join("\n");
|
|
1206
|
+
}
|
|
1207
|
+
function pickDefault(targets) {
|
|
1208
|
+
const detected = targets.findIndex((target) => target.isDetected);
|
|
1209
|
+
return detected >= 0 ? detected : 0;
|
|
1210
|
+
}
|
|
1211
|
+
async function askLaunchTarget(question = null, targets = listLaunchTargets()) {
|
|
1212
|
+
const terminal = question === null ? createTerminalQuestion() : { ask: question, close: () => {
|
|
1213
|
+
} };
|
|
1214
|
+
try {
|
|
1215
|
+
console.log(renderMenu(targets));
|
|
1216
|
+
const fallback = pickDefault(targets);
|
|
1217
|
+
const answer = (await terminal.ask(`Choice [${fallback + 1}]: `)).trim();
|
|
1218
|
+
const index = answer.length === 0 ? fallback : Number.parseInt(answer, 10) - 1;
|
|
1219
|
+
const chosen = targets[index] ?? targets[fallback] ?? { kind: "apple_terminal", label: "", isDetected: false, needsExtension: false };
|
|
1220
|
+
if (chosen.needsExtension && isIdeKind(chosen.kind)) new IdeInstaller().install(chosen.kind);
|
|
1221
|
+
console.log(`Dario will open in ${chosen.label.split(" (")[0]}. Change it any time with "dario terminal <name>" or the in-game settings (C).`);
|
|
1222
|
+
return chosen.kind;
|
|
1223
|
+
} finally {
|
|
1224
|
+
terminal.close();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// src/player/ask_player_name.ts
|
|
1229
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1230
|
+
|
|
1231
|
+
// src/player/player_name.ts
|
|
1232
|
+
var MIN_LENGTH = 2;
|
|
1233
|
+
var MAX_LENGTH = 20;
|
|
1234
|
+
var ALLOWED_PATTERN = /^[\p{L}\p{N} _.-]+$/u;
|
|
1235
|
+
var PlayerName = class _PlayerName {
|
|
1236
|
+
value;
|
|
1237
|
+
constructor(value) {
|
|
1238
|
+
this.value = value;
|
|
1239
|
+
}
|
|
1240
|
+
static create(raw) {
|
|
1241
|
+
const trimmed = raw.trim().replace(/\s+/g, " ");
|
|
1242
|
+
if (trimmed.length < MIN_LENGTH || trimmed.length > MAX_LENGTH) throw new Error(`Name must be ${MIN_LENGTH}-${MAX_LENGTH} characters.`);
|
|
1243
|
+
if (!ALLOWED_PATTERN.test(trimmed)) throw new Error('Name may only contain letters, digits, spaces, "_", "." or "-".');
|
|
1244
|
+
return new _PlayerName(trimmed);
|
|
1245
|
+
}
|
|
1246
|
+
static isValid(raw) {
|
|
1247
|
+
try {
|
|
1248
|
+
_PlayerName.create(raw);
|
|
1249
|
+
return true;
|
|
1250
|
+
} catch {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
// src/player/ask_player_name.ts
|
|
1257
|
+
var YES_ANSWERS = /* @__PURE__ */ new Set(["", "y", "yes", "e", "evet"]);
|
|
1258
|
+
var SOURCE_LABELS = {
|
|
1259
|
+
github: "GitHub",
|
|
1260
|
+
git: "git config",
|
|
1261
|
+
system: "system user"
|
|
1262
|
+
};
|
|
1263
|
+
function createTerminalQuestion2() {
|
|
1264
|
+
const readline = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1265
|
+
return { ask: (prompt) => readline.question(prompt), close: () => readline.close() };
|
|
1266
|
+
}
|
|
1267
|
+
async function askUntilValid(ask) {
|
|
1268
|
+
const answer = await ask("Your name: ");
|
|
1269
|
+
if (PlayerName.isValid(answer)) return PlayerName.create(answer);
|
|
1270
|
+
console.log(`${ANSI.fg(PALETTE.danger)}2-20 characters: letters, digits, spaces, "_", "." or "-".${ANSI.reset}`);
|
|
1271
|
+
return askUntilValid(ask);
|
|
1272
|
+
}
|
|
1273
|
+
async function askPlayerName(detected, question = null) {
|
|
1274
|
+
const terminal = question === null ? createTerminalQuestion2() : { ask: question, close: () => {
|
|
1275
|
+
} };
|
|
1276
|
+
try {
|
|
1277
|
+
const highlighted = `${ANSI.fg(PALETTE.claudeOrange)}${ANSI.bold}${detected.name}${ANSI.reset}`;
|
|
1278
|
+
const answer = await terminal.ask(`Play as (${highlighted}) from ${SOURCE_LABELS[detected.source]}? [Y/n] `);
|
|
1279
|
+
if (YES_ANSWERS.has(answer.trim().toLowerCase())) return PlayerName.create(detected.name);
|
|
1280
|
+
return askUntilValid(terminal.ask);
|
|
1281
|
+
} finally {
|
|
1282
|
+
terminal.close();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/player/detect_player_name.ts
|
|
1287
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
1288
|
+
import { userInfo } from "node:os";
|
|
1289
|
+
var COMMAND_TIMEOUT_MS = 3e3;
|
|
1290
|
+
function runCommand(executable, args) {
|
|
1291
|
+
const result = spawnSync5(executable, [...args], { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS });
|
|
1292
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
1293
|
+
}
|
|
1294
|
+
function detectPlayerName(run = runCommand) {
|
|
1295
|
+
const candidates = [
|
|
1296
|
+
{ name: run("gh", ["api", "user", "--jq", ".login"]), source: "github" },
|
|
1297
|
+
{ name: run("git", ["config", "--global", "user.name"]), source: "git" },
|
|
1298
|
+
{ name: userInfo().username, source: "system" }
|
|
1299
|
+
];
|
|
1300
|
+
const found = candidates.find((candidate) => PlayerName.isValid(candidate.name));
|
|
1301
|
+
return found ?? { name: "player", source: "system" };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/player/run_first_run_setup.ts
|
|
1305
|
+
async function runFirstRunSetup({ force = false } = {}) {
|
|
1306
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
1307
|
+
const config = store.load();
|
|
1308
|
+
if (config.isSetupDone && !force || !process.stdin.isTTY) return;
|
|
1309
|
+
const name = PlayerName.isValid(config.playerName) && !force ? PlayerName.create(config.playerName) : await askPlayerName(detectPlayerName());
|
|
1310
|
+
const terminal = await askLaunchTarget();
|
|
1311
|
+
store.update({ playerName: name.value, terminal, isSetupDone: true });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/cli/commands/install_command.ts
|
|
1315
|
+
function describeReport(report) {
|
|
1316
|
+
return [
|
|
1317
|
+
` claude hooks ${report.hooks} (${DARIO_PATHS.claudeSettingsFile})`,
|
|
1318
|
+
...describeAgentReports(report.agents),
|
|
1319
|
+
` skill link ${report.skill} (${DARIO_PATHS.claudeSkillLink})`,
|
|
1320
|
+
` dario command ${report.bin} (${DARIO_PATHS.localBinLink})`
|
|
1321
|
+
].join("\n");
|
|
1322
|
+
}
|
|
1323
|
+
function describeNotes(reports) {
|
|
1324
|
+
const agents = new AgentHooksInstaller();
|
|
1325
|
+
return reports.filter((report) => report.state === "installed").map((report) => ` ${report.label}: ${agents.getNote(report.kind)}`);
|
|
1326
|
+
}
|
|
1327
|
+
var INSTALL_COMMAND = {
|
|
1328
|
+
name: "install",
|
|
1329
|
+
aliases: ["setup"],
|
|
1330
|
+
usage: "dario install [--setup]",
|
|
1331
|
+
description: "Install the Claude Code hooks (plus Codex / Grok hooks when found), the /dario skill and the dario command.",
|
|
1332
|
+
async execute(args) {
|
|
1333
|
+
if (isNpxCacheInstall()) return relocateNpxInstall({ args });
|
|
1334
|
+
const report = new DarioInstaller().install();
|
|
1335
|
+
console.log(`Dario installed
|
|
1336
|
+
${describeReport(report)}`);
|
|
1337
|
+
describeNotes(report.agents).forEach((note) => console.log(note));
|
|
1338
|
+
await runFirstRunSetup({ force: args.includes("--setup") });
|
|
1339
|
+
console.log('\nFrom now on every prompt you send to Claude Code opens Dario in a new terminal. Use "dario off" to pause that, "dario agents" for Codex / Grok.');
|
|
1340
|
+
return 0;
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
// src/cli/commands/launch_command.ts
|
|
1345
|
+
var LAUNCH_COMMAND = {
|
|
1346
|
+
name: "launch",
|
|
1347
|
+
aliases: ["open", "new"],
|
|
1348
|
+
usage: "dario launch [--as claude|codex|grok]",
|
|
1349
|
+
description: "Open Dario in a new terminal tab/window and start playing.",
|
|
1350
|
+
async execute(args) {
|
|
1351
|
+
const lock = new ProcessLock({ path: DARIO_PATHS.pidFile });
|
|
1352
|
+
if (lock.isHeld()) {
|
|
1353
|
+
console.log(`Dario is already running (pid ${lock.readPid()}).`);
|
|
1354
|
+
return 0;
|
|
1355
|
+
}
|
|
1356
|
+
const config = new ConfigStore({ path: DARIO_PATHS.configFile }).load();
|
|
1357
|
+
const agent = resolveAgent({ args, configured: config.character, env: process.env });
|
|
1358
|
+
const result = new TerminalLauncher().launch({ preferred: config.terminal, agent, lock });
|
|
1359
|
+
const note = result.kind !== config.terminal && config.terminal !== "auto" ? ` (${config.terminal} did not open the game; reload the IDE window and allow the Dario Launcher URI)` : "";
|
|
1360
|
+
console.log(`Opened Dario in ${result.kind}${note}.`);
|
|
1361
|
+
return 0;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
// src/game/game_mode_labels.ts
|
|
1366
|
+
var GAME_MODE_LABELS = { vibe: "VIBE CODER", pro: "PRO CODER" };
|
|
1367
|
+
|
|
1368
|
+
// src/cli/commands/mode_command.ts
|
|
1369
|
+
var MODES = ["vibe", "pro"];
|
|
1370
|
+
function isGameMode(value) {
|
|
1371
|
+
return MODES.includes(value);
|
|
1372
|
+
}
|
|
1373
|
+
var MODE_COMMAND = {
|
|
1374
|
+
name: "mode",
|
|
1375
|
+
aliases: [],
|
|
1376
|
+
usage: `dario mode <${MODES.join("|")}>`,
|
|
1377
|
+
description: `Game mode: ${GAME_MODE_LABELS.vibe} plays any time (default), ${GAME_MODE_LABELS.pro} only while Claude/Codex/Grok is working.`,
|
|
1378
|
+
async execute(args) {
|
|
1379
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
1380
|
+
const value = (args[0] ?? "").toLowerCase();
|
|
1381
|
+
if (value.length === 0) {
|
|
1382
|
+
console.log(`Mode: ${GAME_MODE_LABELS[store.load().mode]}`);
|
|
1383
|
+
return 0;
|
|
1384
|
+
}
|
|
1385
|
+
if (!isGameMode(value)) {
|
|
1386
|
+
console.error(`Unknown mode "${value}". Options: ${MODES.join(", ")}`);
|
|
1387
|
+
return 1;
|
|
1388
|
+
}
|
|
1389
|
+
console.log(`Mode is now ${GAME_MODE_LABELS[store.update({ mode: value }).mode]}.`);
|
|
1390
|
+
return 0;
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
// src/cli/commands/name_command.ts
|
|
1395
|
+
var DETECT_FLAG = "--detect";
|
|
1396
|
+
async function chooseName(args) {
|
|
1397
|
+
if (args[0] === DETECT_FLAG) return askPlayerName(detectPlayerName());
|
|
1398
|
+
return PlayerName.create(args.join(" "));
|
|
1399
|
+
}
|
|
1400
|
+
var NAME_COMMAND = {
|
|
1401
|
+
name: "name",
|
|
1402
|
+
aliases: ["player", "whoami"],
|
|
1403
|
+
usage: "dario name [<new name> | --detect]",
|
|
1404
|
+
description: "Show or change the name used on the leaderboard (--detect re-reads GitHub/git).",
|
|
1405
|
+
async execute(args) {
|
|
1406
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
1407
|
+
if (args.length === 0) {
|
|
1408
|
+
const current = store.load().playerName;
|
|
1409
|
+
console.log(current.length === 0 ? 'No name yet: it will be asked on the first "dario play".' : `Playing as ${current}.`);
|
|
1410
|
+
return 0;
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
const chosen = await chooseName(args);
|
|
1414
|
+
store.update({ playerName: chosen.value });
|
|
1415
|
+
console.log(`Playing as ${chosen.value}.`);
|
|
1416
|
+
return 0;
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1419
|
+
return 1;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
|
|
1424
|
+
// src/claude/claude_status_watcher.ts
|
|
1425
|
+
var ClaudeStatusWatcher = class {
|
|
1426
|
+
file;
|
|
1427
|
+
current;
|
|
1428
|
+
lastSeenAt;
|
|
1429
|
+
constructor({ path }) {
|
|
1430
|
+
this.file = new JsonFile({ path, fallback: null });
|
|
1431
|
+
this.current = this.file.read();
|
|
1432
|
+
this.lastSeenAt = this.current?.updatedAt ?? 0;
|
|
1433
|
+
}
|
|
1434
|
+
getCurrent() {
|
|
1435
|
+
return this.current;
|
|
1436
|
+
}
|
|
1437
|
+
pollChange() {
|
|
1438
|
+
const record = this.file.read();
|
|
1439
|
+
if (record === null || record.updatedAt === this.lastSeenAt) return null;
|
|
1440
|
+
this.lastSeenAt = record.updatedAt;
|
|
1441
|
+
this.current = record;
|
|
1442
|
+
return record;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
// src/input/composite_input.ts
|
|
1447
|
+
var CompositeInput = class {
|
|
1448
|
+
sources;
|
|
1449
|
+
constructor(sources) {
|
|
1450
|
+
this.sources = sources;
|
|
1451
|
+
}
|
|
1452
|
+
start(handler) {
|
|
1453
|
+
this.sources.forEach((source) => source.start(handler));
|
|
1454
|
+
}
|
|
1455
|
+
stop() {
|
|
1456
|
+
this.sources.forEach((source) => source.stop());
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
// src/input/key_state_watcher.ts
|
|
1461
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1462
|
+
|
|
1463
|
+
// src/input/key_state_script.ts
|
|
1464
|
+
var KEY_STATE_SCRIPT = [
|
|
1465
|
+
"import ctypes, os, sys, time",
|
|
1466
|
+
"cg = ctypes.cdll.LoadLibrary('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')",
|
|
1467
|
+
"cg.CGEventSourceKeyState.restype = ctypes.c_bool",
|
|
1468
|
+
"cg.CGEventSourceKeyState.argtypes = [ctypes.c_int32, ctypes.c_uint16]",
|
|
1469
|
+
"KEYS = {'R': (124, 2), 'L': (123, 0)}",
|
|
1470
|
+
"prev = {k: False for k in KEYS}",
|
|
1471
|
+
"parent = os.getppid()",
|
|
1472
|
+
"tick = 0",
|
|
1473
|
+
"while True:",
|
|
1474
|
+
" for name, codes in KEYS.items():",
|
|
1475
|
+
" down = any(cg.CGEventSourceKeyState(1, c) for c in codes)",
|
|
1476
|
+
" if down != prev[name]:",
|
|
1477
|
+
" prev[name] = down",
|
|
1478
|
+
" sys.stdout.write(f'{name}{1 if down else 0}\\n')",
|
|
1479
|
+
" sys.stdout.flush()",
|
|
1480
|
+
" tick += 1",
|
|
1481
|
+
" if tick % 60 == 0 and os.getppid() != parent:",
|
|
1482
|
+
" sys.exit(0)",
|
|
1483
|
+
" time.sleep(0.016)"
|
|
1484
|
+
].join("\n");
|
|
1485
|
+
|
|
1486
|
+
// src/input/parse_key_state_line.ts
|
|
1487
|
+
var RELEASE_BY_KEY = { R: "right_release", L: "left_release" };
|
|
1488
|
+
function parseKeyStateLine(line) {
|
|
1489
|
+
const trimmed = line.trim();
|
|
1490
|
+
if (trimmed.length !== 2 || trimmed.charAt(1) !== "0") return null;
|
|
1491
|
+
return RELEASE_BY_KEY[trimmed.charAt(0)] ?? null;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// src/input/key_state_watcher.ts
|
|
1495
|
+
var PYTHON = "python3";
|
|
1496
|
+
var KeyStateWatcher = class {
|
|
1497
|
+
child = null;
|
|
1498
|
+
handler = null;
|
|
1499
|
+
hasAnnounced = false;
|
|
1500
|
+
buffer = "";
|
|
1501
|
+
start(handler) {
|
|
1502
|
+
this.handler = handler;
|
|
1503
|
+
try {
|
|
1504
|
+
this.child = spawn2(PYTHON, ["-c", KEY_STATE_SCRIPT], { stdio: ["ignore", "pipe", "ignore"] });
|
|
1505
|
+
this.child.stdout?.setEncoding("utf8");
|
|
1506
|
+
this.child.stdout?.on("data", (chunk) => this.consume(chunk));
|
|
1507
|
+
this.child.on("error", () => {
|
|
1508
|
+
this.child = null;
|
|
1509
|
+
});
|
|
1510
|
+
} catch {
|
|
1511
|
+
this.child = null;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
stop() {
|
|
1515
|
+
this.handler = null;
|
|
1516
|
+
this.child?.kill();
|
|
1517
|
+
this.child = null;
|
|
1518
|
+
}
|
|
1519
|
+
consume(chunk) {
|
|
1520
|
+
this.buffer += chunk;
|
|
1521
|
+
const lines = this.buffer.split("\n");
|
|
1522
|
+
this.buffer = lines.pop() ?? "";
|
|
1523
|
+
lines.forEach((line) => this.emitLine(line));
|
|
1524
|
+
}
|
|
1525
|
+
emitLine(line) {
|
|
1526
|
+
const action = parseKeyStateLine(line);
|
|
1527
|
+
if (action === null || this.handler === null) return;
|
|
1528
|
+
if (!this.hasAnnounced) {
|
|
1529
|
+
this.hasAnnounced = true;
|
|
1530
|
+
this.handler("hold_reliable");
|
|
1531
|
+
}
|
|
1532
|
+
this.handler(action);
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
// src/input/keyboard.ts
|
|
1537
|
+
var ESCAPE = "\x1B";
|
|
1538
|
+
var CONTROL_C = "";
|
|
1539
|
+
var RELEASE_EVENT = 3;
|
|
1540
|
+
var TOKEN_PATTERN = /\u001b\[[0-9;:?]*[A-Za-z~]|\u001bO[A-Za-z]|[\s\S]/gu;
|
|
1541
|
+
var CSI_PATTERN = /^\u001b\[([0-9;:?]*)([A-Za-z~])$/u;
|
|
1542
|
+
var INCOMPLETE_PATTERN = /(?:\u001bO|\u001b\[[0-9;:?]*|\u001b)$/u;
|
|
1543
|
+
var INCOMPLETE_FLUSH_MS = 40;
|
|
1544
|
+
var KEY_BY_CHAR = {
|
|
1545
|
+
" ": "jump",
|
|
1546
|
+
w: "jump",
|
|
1547
|
+
k: "jump",
|
|
1548
|
+
s: "duck",
|
|
1549
|
+
j: "duck",
|
|
1550
|
+
d: "right",
|
|
1551
|
+
l: "right",
|
|
1552
|
+
a: "left",
|
|
1553
|
+
h: "left",
|
|
1554
|
+
p: "pause",
|
|
1555
|
+
r: "restart",
|
|
1556
|
+
q: "quit",
|
|
1557
|
+
c: "settings",
|
|
1558
|
+
[ESCAPE]: "quit",
|
|
1559
|
+
[CONTROL_C]: "quit"
|
|
1560
|
+
};
|
|
1561
|
+
var KEY_BY_ARROW = { A: "up", B: "down", C: "right", D: "left" };
|
|
1562
|
+
var RELEASE_BY_ACTION = { left: "left_release", right: "right_release" };
|
|
1563
|
+
function parseCsi(params, final) {
|
|
1564
|
+
if (params.startsWith("?") && final === "u") return null;
|
|
1565
|
+
const [, modifiersAndEvent = ""] = params.split(";");
|
|
1566
|
+
const isRelease = Number.parseInt(modifiersAndEvent.split(":")[1] ?? "1", 10) === RELEASE_EVENT;
|
|
1567
|
+
if (final === "u") {
|
|
1568
|
+
const code = Number.parseInt(params, 10);
|
|
1569
|
+
const action = KEY_BY_CHAR[String.fromCodePoint(Number.isNaN(code) ? 0 : code)];
|
|
1570
|
+
return action === void 0 ? null : { action, isRelease };
|
|
1571
|
+
}
|
|
1572
|
+
const arrow = KEY_BY_ARROW[final];
|
|
1573
|
+
return arrow === void 0 ? null : { action: arrow, isRelease };
|
|
1574
|
+
}
|
|
1575
|
+
function parseToken(token) {
|
|
1576
|
+
const csi = CSI_PATTERN.exec(token);
|
|
1577
|
+
if (csi !== null) return parseCsi(csi[1] ?? "", csi[2] ?? "");
|
|
1578
|
+
if (token.startsWith(`${ESCAPE}O`)) return parseCsi("", token.slice(2));
|
|
1579
|
+
const action = KEY_BY_CHAR[token] ?? KEY_BY_CHAR[token.toLowerCase()];
|
|
1580
|
+
return action === void 0 ? null : { action, isRelease: false };
|
|
1581
|
+
}
|
|
1582
|
+
var Keyboard = class {
|
|
1583
|
+
input;
|
|
1584
|
+
handler = null;
|
|
1585
|
+
pending = "";
|
|
1586
|
+
flushTimer = null;
|
|
1587
|
+
hasSeenRelease = false;
|
|
1588
|
+
onData = (chunk) => this.dispatch(chunk);
|
|
1589
|
+
constructor(input = process.stdin) {
|
|
1590
|
+
this.input = input;
|
|
1591
|
+
}
|
|
1592
|
+
start(handler) {
|
|
1593
|
+
this.handler = handler;
|
|
1594
|
+
if (this.input.isTTY) this.input.setRawMode(true);
|
|
1595
|
+
this.input.setEncoding("utf8");
|
|
1596
|
+
this.input.on("data", this.onData);
|
|
1597
|
+
this.input.resume();
|
|
1598
|
+
}
|
|
1599
|
+
stop() {
|
|
1600
|
+
this.handler = null;
|
|
1601
|
+
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
|
|
1602
|
+
this.input.off("data", this.onData);
|
|
1603
|
+
if (this.input.isTTY) this.input.setRawMode(false);
|
|
1604
|
+
this.input.pause();
|
|
1605
|
+
}
|
|
1606
|
+
/** Feed raw terminal bytes; an unfinished escape at the end waits briefly for the rest (so ESC alone still quits). */
|
|
1607
|
+
dispatch(chunk) {
|
|
1608
|
+
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
|
|
1609
|
+
const data = this.pending + chunk;
|
|
1610
|
+
const incomplete = INCOMPLETE_PATTERN.exec(data);
|
|
1611
|
+
const complete = incomplete === null ? data : data.slice(0, incomplete.index);
|
|
1612
|
+
this.pending = incomplete === null ? "" : incomplete[0];
|
|
1613
|
+
this.emitTokens(complete);
|
|
1614
|
+
if (this.pending.length > 0) this.flushTimer = setTimeout(() => this.flush(), INCOMPLETE_FLUSH_MS);
|
|
1615
|
+
}
|
|
1616
|
+
/** Give up waiting for a continuation: whatever is buffered is parsed as-is. */
|
|
1617
|
+
flush() {
|
|
1618
|
+
const data = this.pending;
|
|
1619
|
+
this.pending = "";
|
|
1620
|
+
this.flushTimer = null;
|
|
1621
|
+
this.emitTokens(data);
|
|
1622
|
+
}
|
|
1623
|
+
emitTokens(data) {
|
|
1624
|
+
const tokens = data.match(TOKEN_PATTERN) ?? [];
|
|
1625
|
+
tokens.forEach((token) => this.emitParsed(parseToken(token)));
|
|
1626
|
+
}
|
|
1627
|
+
emitParsed(parsed) {
|
|
1628
|
+
if (parsed === null) return;
|
|
1629
|
+
if (parsed === "hold_support") return this.emit("hold_support");
|
|
1630
|
+
if (!parsed.isRelease) return this.emit(parsed.action);
|
|
1631
|
+
this.announceReleaseSupport();
|
|
1632
|
+
const release = RELEASE_BY_ACTION[parsed.action];
|
|
1633
|
+
if (release !== void 0) this.emit(release);
|
|
1634
|
+
}
|
|
1635
|
+
/** Hold mode is enabled by proof, not promise: the first real release event we see. */
|
|
1636
|
+
announceReleaseSupport() {
|
|
1637
|
+
if (this.hasSeenRelease) return;
|
|
1638
|
+
this.hasSeenRelease = true;
|
|
1639
|
+
this.emit("hold_support");
|
|
1640
|
+
}
|
|
1641
|
+
emit(action) {
|
|
1642
|
+
if (this.handler !== null) this.handler(action);
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// src/config/firebase_config.ts
|
|
1647
|
+
var FIREBASE_CONFIG = {
|
|
1648
|
+
projectId: "dario-xxx",
|
|
1649
|
+
apiKey: "AIzaSyBt3-mnnah4A8CSIlt2fw0kJ0MveRurJq0",
|
|
1650
|
+
playersCollection: "players",
|
|
1651
|
+
sponsorsCollection: "sponsors",
|
|
1652
|
+
siteUrl: "https://dario-xxx.web.app",
|
|
1653
|
+
requestTimeoutMs: 6e3
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// src/ranking/http_json.ts
|
|
1657
|
+
async function httpJson({ url, method, body, token, timeoutMs, fetchFn = fetch }) {
|
|
1658
|
+
const headers = { "Content-Type": "application/json" };
|
|
1659
|
+
if (token !== void 0) headers.Authorization = `Bearer ${token}`;
|
|
1660
|
+
const response = await fetchFn(url, { method, headers, body: body === void 0 ? void 0 : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
|
|
1661
|
+
const payload = await response.json().catch(() => ({}));
|
|
1662
|
+
if (!response.ok) throw new Error(payload.error?.message ?? `HTTP ${response.status}`);
|
|
1663
|
+
return payload;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/ranking/firebase_anonymous_auth.ts
|
|
1667
|
+
var SIGN_UP_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signUp";
|
|
1668
|
+
var REFRESH_URL = "https://securetoken.googleapis.com/v1/token";
|
|
1669
|
+
var EXPIRY_MARGIN_MS = 6e4;
|
|
1670
|
+
var MS_PER_SECOND2 = 1e3;
|
|
1671
|
+
var FirebaseAnonymousAuth = class {
|
|
1672
|
+
apiKey;
|
|
1673
|
+
file;
|
|
1674
|
+
timeoutMs;
|
|
1675
|
+
fetchFn;
|
|
1676
|
+
now;
|
|
1677
|
+
constructor({ apiKey, file, timeoutMs, fetchFn = fetch, now = Date.now }) {
|
|
1678
|
+
this.apiKey = apiKey;
|
|
1679
|
+
this.file = file;
|
|
1680
|
+
this.timeoutMs = timeoutMs;
|
|
1681
|
+
this.fetchFn = fetchFn;
|
|
1682
|
+
this.now = now;
|
|
1683
|
+
}
|
|
1684
|
+
getUid() {
|
|
1685
|
+
return this.file.read()?.uid ?? null;
|
|
1686
|
+
}
|
|
1687
|
+
async getSession() {
|
|
1688
|
+
const saved = this.file.read();
|
|
1689
|
+
if (saved === null) return this.signUp();
|
|
1690
|
+
if (saved.expiresAt - EXPIRY_MARGIN_MS > this.now()) return saved;
|
|
1691
|
+
return this.refresh(saved);
|
|
1692
|
+
}
|
|
1693
|
+
async signUp() {
|
|
1694
|
+
const url = `${SIGN_UP_URL}?key=${this.apiKey}`;
|
|
1695
|
+
const response = await httpJson({ url, method: "POST", body: { returnSecureToken: true }, timeoutMs: this.timeoutMs, fetchFn: this.fetchFn });
|
|
1696
|
+
return this.save({ uid: response.localId, idToken: response.idToken, refreshToken: response.refreshToken, expiresIn: response.expiresIn });
|
|
1697
|
+
}
|
|
1698
|
+
async refresh(saved) {
|
|
1699
|
+
const url = `${REFRESH_URL}?key=${this.apiKey}`;
|
|
1700
|
+
const body = { grant_type: "refresh_token", refresh_token: saved.refreshToken };
|
|
1701
|
+
const response = await httpJson({ url, method: "POST", body, timeoutMs: this.timeoutMs, fetchFn: this.fetchFn });
|
|
1702
|
+
return this.save({ uid: response.user_id, idToken: response.id_token, refreshToken: response.refresh_token, expiresIn: response.expires_in });
|
|
1703
|
+
}
|
|
1704
|
+
save({ uid, idToken, refreshToken, expiresIn }) {
|
|
1705
|
+
const record = { uid, idToken, refreshToken, expiresAt: this.now() + Number.parseInt(expiresIn, 10) * MS_PER_SECOND2 };
|
|
1706
|
+
this.file.write(record);
|
|
1707
|
+
return record;
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
// src/ranking/decode_firestore_fields.ts
|
|
1712
|
+
function decodeValue(value) {
|
|
1713
|
+
if ("integerValue" in value) return Number.parseInt(value.integerValue, 10);
|
|
1714
|
+
if ("doubleValue" in value) return value.doubleValue;
|
|
1715
|
+
if ("booleanValue" in value) return value.booleanValue;
|
|
1716
|
+
if ("timestampValue" in value) return value.timestampValue;
|
|
1717
|
+
if ("nullValue" in value) return null;
|
|
1718
|
+
if ("arrayValue" in value) return (value.arrayValue.values ?? []).map(decodeValue);
|
|
1719
|
+
if ("mapValue" in value) return decodeFirestoreFields(value.mapValue.fields);
|
|
1720
|
+
return value.stringValue;
|
|
1721
|
+
}
|
|
1722
|
+
function decodeFirestoreFields(fields) {
|
|
1723
|
+
return Object.fromEntries(Object.entries(fields ?? {}).map(([key, value]) => [key, decodeValue(value)]));
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/ranking/encode_firestore_fields.ts
|
|
1727
|
+
function encodeValue(value) {
|
|
1728
|
+
if (value instanceof Date) return { timestampValue: value.toISOString() };
|
|
1729
|
+
if (typeof value === "boolean") return { booleanValue: value };
|
|
1730
|
+
if (typeof value === "number") return { integerValue: String(Math.trunc(value)) };
|
|
1731
|
+
return { stringValue: value };
|
|
1732
|
+
}
|
|
1733
|
+
function encodeFirestoreFields(data) {
|
|
1734
|
+
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, encodeValue(value)]));
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/ranking/firestore_client.ts
|
|
1738
|
+
var BASE_URL = "https://firestore.googleapis.com/v1";
|
|
1739
|
+
function buildStructuredQuery({ collection, where, orderByDescending, limit }) {
|
|
1740
|
+
return {
|
|
1741
|
+
from: [{ collectionId: collection }],
|
|
1742
|
+
...where === void 0 ? {} : { where: { fieldFilter: { field: { fieldPath: where.field }, op: "EQUAL", value: { stringValue: where.equals } } } },
|
|
1743
|
+
...orderByDescending === void 0 ? {} : { orderBy: [{ field: { fieldPath: orderByDescending }, direction: "DESCENDING" }] },
|
|
1744
|
+
limit
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
var FirestoreClient = class {
|
|
1748
|
+
documentsUrl;
|
|
1749
|
+
timeoutMs;
|
|
1750
|
+
fetchFn;
|
|
1751
|
+
constructor({ projectId, timeoutMs, fetchFn = fetch }) {
|
|
1752
|
+
this.documentsUrl = `${BASE_URL}/projects/${projectId}/databases/(default)/documents`;
|
|
1753
|
+
this.timeoutMs = timeoutMs;
|
|
1754
|
+
this.fetchFn = fetchFn;
|
|
1755
|
+
}
|
|
1756
|
+
/** PATCH with an update mask both creates and updates, so security rules see the merged document. */
|
|
1757
|
+
async upsert({ collection, documentId, data, token }) {
|
|
1758
|
+
const mask = Object.keys(data).map((key) => `updateMask.fieldPaths=${encodeURIComponent(key)}`).join("&");
|
|
1759
|
+
const url = `${this.documentsUrl}/${collection}/${encodeURIComponent(documentId)}?${mask}`;
|
|
1760
|
+
await httpJson({ url, method: "PATCH", body: { fields: encodeFirestoreFields(data) }, token, timeoutMs: this.timeoutMs, fetchFn: this.fetchFn });
|
|
1761
|
+
}
|
|
1762
|
+
async query(input) {
|
|
1763
|
+
const structuredQuery = buildStructuredQuery(input);
|
|
1764
|
+
const rows = await httpJson({ url: `${this.documentsUrl}:runQuery`, method: "POST", body: { structuredQuery }, timeoutMs: this.timeoutMs, fetchFn: this.fetchFn });
|
|
1765
|
+
return rows.filter((row) => row.document !== void 0).map((row) => toDocument(row.document));
|
|
1766
|
+
}
|
|
1767
|
+
async top({ collection, orderBy, limit }) {
|
|
1768
|
+
return this.query({ collection, orderByDescending: orderBy, limit });
|
|
1769
|
+
}
|
|
1770
|
+
async countAbove({ collection, field, value }) {
|
|
1771
|
+
const where = { fieldFilter: { field: { fieldPath: field }, op: "GREATER_THAN", value: { integerValue: String(Math.trunc(value)) } } };
|
|
1772
|
+
const structuredAggregationQuery = { structuredQuery: { from: [{ collectionId: collection }], where }, aggregations: [{ count: {}, alias: "above" }] };
|
|
1773
|
+
const rows = await httpJson({ url: `${this.documentsUrl}:runAggregationQuery`, method: "POST", body: { structuredAggregationQuery }, timeoutMs: this.timeoutMs, fetchFn: this.fetchFn });
|
|
1774
|
+
const aggregate = rows[0]?.result?.aggregateFields?.above;
|
|
1775
|
+
return aggregate !== void 0 && "integerValue" in aggregate ? Number.parseInt(aggregate.integerValue, 10) : 0;
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
function toDocument(raw) {
|
|
1779
|
+
return { id: raw.name.slice(raw.name.lastIndexOf("/") + 1), data: decodeFirestoreFields(raw.fields) };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// src/ranking/leaderboard_service.ts
|
|
1783
|
+
var SCORE_FIELDS = { vibe: "highScore", pro: "proHighScore" };
|
|
1784
|
+
var DEFAULT_TOP_LIMIT = 10;
|
|
1785
|
+
var DEFAULT_MODE = "vibe";
|
|
1786
|
+
function toEntry({ document, mode }) {
|
|
1787
|
+
return {
|
|
1788
|
+
uid: document.id,
|
|
1789
|
+
name: String(document.data.name ?? "?"),
|
|
1790
|
+
highScore: Number(document.data[SCORE_FIELDS[mode]] ?? 0),
|
|
1791
|
+
bestTokens: Number(document.data.bestTokens ?? 0),
|
|
1792
|
+
gamesPlayed: Number(document.data.gamesPlayed ?? 0)
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
var LeaderboardService = class {
|
|
1796
|
+
auth;
|
|
1797
|
+
firestore;
|
|
1798
|
+
collection;
|
|
1799
|
+
constructor({ auth, firestore, collection }) {
|
|
1800
|
+
this.auth = auth;
|
|
1801
|
+
this.firestore = firestore;
|
|
1802
|
+
this.collection = collection;
|
|
1803
|
+
}
|
|
1804
|
+
async submit({ name, record, mode = DEFAULT_MODE }) {
|
|
1805
|
+
const session = await this.auth.getSession();
|
|
1806
|
+
const data = { name, highScore: record.highScore, proHighScore: record.proHighScore, bestTokens: record.bestTokens, gamesPlayed: record.gamesPlayed, updatedAt: /* @__PURE__ */ new Date() };
|
|
1807
|
+
await this.firestore.upsert({ collection: this.collection, documentId: session.uid, data, token: session.idToken });
|
|
1808
|
+
const score = mode === "pro" ? record.proHighScore : record.highScore;
|
|
1809
|
+
return this.buildResult({ limit: DEFAULT_TOP_LIMIT, uid: session.uid, score, mode });
|
|
1810
|
+
}
|
|
1811
|
+
/** Rank inside the top list is its position; outside it (with a known score) it is "players above me" + 1. */
|
|
1812
|
+
async fetchRanking({ limit, mode = DEFAULT_MODE, score = 0 }) {
|
|
1813
|
+
const uid = this.auth.getUid() ?? "";
|
|
1814
|
+
const top = await this.fetchTop({ limit, mode });
|
|
1815
|
+
const mine = top.find((entry) => entry.uid === uid);
|
|
1816
|
+
if (mine !== void 0) return { top, uid, rank: top.indexOf(mine) + 1 };
|
|
1817
|
+
if (uid.length === 0 || score <= 0) return { top, uid, rank: null };
|
|
1818
|
+
const above = await this.firestore.countAbove({ collection: this.collection, field: SCORE_FIELDS[mode], value: score });
|
|
1819
|
+
return { top, uid, rank: above + 1 };
|
|
1820
|
+
}
|
|
1821
|
+
async buildResult({ limit, uid, score, mode }) {
|
|
1822
|
+
const [top, above] = await Promise.all([this.fetchTop({ limit, mode }), this.firestore.countAbove({ collection: this.collection, field: SCORE_FIELDS[mode], value: score })]);
|
|
1823
|
+
return { top, uid, rank: above + 1 };
|
|
1824
|
+
}
|
|
1825
|
+
async fetchTop({ limit, mode }) {
|
|
1826
|
+
const documents = await this.firestore.top({ collection: this.collection, orderBy: SCORE_FIELDS[mode], limit });
|
|
1827
|
+
return documents.map((document) => toEntry({ document, mode }));
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
// src/ranking/create_leaderboard.ts
|
|
1832
|
+
function createLeaderboard() {
|
|
1833
|
+
const auth = new FirebaseAnonymousAuth({
|
|
1834
|
+
apiKey: FIREBASE_CONFIG.apiKey,
|
|
1835
|
+
file: new JsonFile({ path: DARIO_PATHS.authFile, fallback: null }),
|
|
1836
|
+
timeoutMs: FIREBASE_CONFIG.requestTimeoutMs
|
|
1837
|
+
});
|
|
1838
|
+
const firestore = new FirestoreClient({ projectId: FIREBASE_CONFIG.projectId, timeoutMs: FIREBASE_CONFIG.requestTimeoutMs });
|
|
1839
|
+
return new LeaderboardService({ auth, firestore, collection: FIREBASE_CONFIG.playersCollection });
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// src/sponsors/parse_sponsor.ts
|
|
1843
|
+
var HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
1844
|
+
var URL_PATTERN = /^https?:\/\//i;
|
|
1845
|
+
var LABEL_MAX = 16;
|
|
1846
|
+
var DEFAULT_FG = "#ffffff";
|
|
1847
|
+
var DEFAULT_BG = "#d97757";
|
|
1848
|
+
var LEGACY_TIERS = { token: "mystery", billboard: "banner" };
|
|
1849
|
+
function readTier(value) {
|
|
1850
|
+
if (value === "mystery" || value === "banner" || value === "ranking") return value;
|
|
1851
|
+
return LEGACY_TIERS[String(value)] ?? null;
|
|
1852
|
+
}
|
|
1853
|
+
function readRows(value) {
|
|
1854
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
1855
|
+
const rows = value.filter((row) => typeof row === "string");
|
|
1856
|
+
const width = rows[0]?.length ?? 0;
|
|
1857
|
+
return rows.length === value.length && width > 0 && rows.every((row) => row.length === width) ? rows : null;
|
|
1858
|
+
}
|
|
1859
|
+
function readPalette(value) {
|
|
1860
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1861
|
+
const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string" && HEX_PATTERN.test(entry[1]));
|
|
1862
|
+
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
|
1863
|
+
}
|
|
1864
|
+
function readDays(value) {
|
|
1865
|
+
return Array.isArray(value) ? value.filter((day) => typeof day === "string") : [];
|
|
1866
|
+
}
|
|
1867
|
+
function parseSponsor(document) {
|
|
1868
|
+
const { data } = document;
|
|
1869
|
+
const style = data.style === "text" ? "text" : "pixel";
|
|
1870
|
+
const tier = readTier(data.tier);
|
|
1871
|
+
const hasNoLogo = style === "text" || tier === "ranking";
|
|
1872
|
+
const rows = readRows(data.rows) ?? (hasNoLogo ? [] : null);
|
|
1873
|
+
const palette = readPalette(data.palette) ?? (hasNoLogo ? {} : null);
|
|
1874
|
+
const url = String(data.url ?? "");
|
|
1875
|
+
const name = String(data.name ?? "").trim();
|
|
1876
|
+
const label = String(data.label ?? name).trim().slice(0, LABEL_MAX);
|
|
1877
|
+
const fg = HEX_PATTERN.test(String(data.fg)) ? String(data.fg) : DEFAULT_FG;
|
|
1878
|
+
const bg = HEX_PATTERN.test(String(data.bg)) ? String(data.bg) : DEFAULT_BG;
|
|
1879
|
+
if (rows === null || palette === null || tier === null || !URL_PATTERN.test(url) || name.length === 0) return null;
|
|
1880
|
+
if (style === "text" && label.length === 0) return null;
|
|
1881
|
+
return { id: document.id, name, url, tier, rows, palette, expiresAt: Number(data.expiresAt ?? 0), style, label, fg, bg, priority: Number(data.priority ?? 0), paidAt: Number(data.paidAt ?? 0), days: readDays(data.days) };
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// src/sponsors/sort_sponsors.ts
|
|
1885
|
+
function sortSponsors(sponsors) {
|
|
1886
|
+
return [...sponsors].sort((a, b) => b.priority - a.priority || a.paidAt - b.paidAt || a.name.localeCompare(b.name));
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// src/sponsors/sponsor_service.ts
|
|
1890
|
+
var ACTIVE_STATUS = "active";
|
|
1891
|
+
var MAX_SPONSORS = 24;
|
|
1892
|
+
var SponsorService = class {
|
|
1893
|
+
firestore;
|
|
1894
|
+
collection;
|
|
1895
|
+
cache;
|
|
1896
|
+
cacheTtlMs;
|
|
1897
|
+
now;
|
|
1898
|
+
constructor({ firestore, collection, cache, cacheTtlMs, now = Date.now }) {
|
|
1899
|
+
this.firestore = firestore;
|
|
1900
|
+
this.collection = collection;
|
|
1901
|
+
this.cache = cache;
|
|
1902
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
1903
|
+
this.now = now;
|
|
1904
|
+
}
|
|
1905
|
+
async loadSponsors() {
|
|
1906
|
+
const cached = this.cache.read();
|
|
1907
|
+
if (cached !== null && this.now() - cached.fetchedAt < this.cacheTtlMs) return this.dropExpired(cached.sponsors);
|
|
1908
|
+
try {
|
|
1909
|
+
const sponsors = await this.fetchActive();
|
|
1910
|
+
this.cache.write({ fetchedAt: this.now(), sponsors });
|
|
1911
|
+
return this.dropExpired(sponsors);
|
|
1912
|
+
} catch {
|
|
1913
|
+
return this.dropExpired(cached?.sponsors ?? []);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
async fetchActive() {
|
|
1917
|
+
const documents = await this.firestore.query({ collection: this.collection, where: { field: "status", equals: ACTIVE_STATUS }, limit: MAX_SPONSORS });
|
|
1918
|
+
return documents.map(parseSponsor).filter((sponsor) => sponsor !== null);
|
|
1919
|
+
}
|
|
1920
|
+
/** Live sponsors in display order: the first token sponsor owns the most blocks, the first banner sits closest to the title. */
|
|
1921
|
+
dropExpired(sponsors) {
|
|
1922
|
+
return sortSponsors(sponsors.filter((sponsor) => sponsor.expiresAt > this.now()));
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
// src/sponsors/create_sponsor_service.ts
|
|
1927
|
+
var CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
1928
|
+
function createSponsorService() {
|
|
1929
|
+
return new SponsorService({
|
|
1930
|
+
firestore: new FirestoreClient({ projectId: FIREBASE_CONFIG.projectId, timeoutMs: FIREBASE_CONFIG.requestTimeoutMs }),
|
|
1931
|
+
collection: FIREBASE_CONFIG.sponsorsCollection,
|
|
1932
|
+
cache: new JsonFile({ path: DARIO_PATHS.sponsorsCacheFile, fallback: null }),
|
|
1933
|
+
cacheTtlMs: CACHE_TTL_MS
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// src/render/terminal_screen.ts
|
|
1938
|
+
var FALLBACK_SIZE = { columns: 80, rows: 24 };
|
|
1939
|
+
var TerminalScreen = class {
|
|
1940
|
+
output;
|
|
1941
|
+
isActive = false;
|
|
1942
|
+
constructor(output = process.stdout) {
|
|
1943
|
+
this.output = output;
|
|
1944
|
+
}
|
|
1945
|
+
getSize() {
|
|
1946
|
+
return {
|
|
1947
|
+
columns: this.output.columns ?? FALLBACK_SIZE.columns,
|
|
1948
|
+
rows: this.output.rows ?? FALLBACK_SIZE.rows
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
enter() {
|
|
1952
|
+
if (this.isActive) return;
|
|
1953
|
+
this.isActive = true;
|
|
1954
|
+
this.output.write(ANSI.enterAltScreen + ANSI.kittyKeyboardOn + ANSI.hideCursor + ANSI.clearScreen + ANSI.home);
|
|
1955
|
+
}
|
|
1956
|
+
exit() {
|
|
1957
|
+
if (!this.isActive) return;
|
|
1958
|
+
this.isActive = false;
|
|
1959
|
+
this.output.write(ANSI.reset + ANSI.showCursor + ANSI.kittyKeyboardOff + ANSI.exitAltScreen);
|
|
1960
|
+
}
|
|
1961
|
+
render(lines) {
|
|
1962
|
+
this.output.write(ANSI.home + lines.join("\r\n"));
|
|
1963
|
+
}
|
|
1964
|
+
onResize(handler) {
|
|
1965
|
+
this.output.on("resize", handler);
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
// src/store/score_store.ts
|
|
1970
|
+
var EMPTY_RECORD = { highScore: 0, proHighScore: 0, bestTokens: 0, totalTokens: 0, gamesPlayed: 0, lastScore: 0, updatedAt: 0 };
|
|
1971
|
+
var ScoreStore = class {
|
|
1972
|
+
file;
|
|
1973
|
+
constructor({ path }) {
|
|
1974
|
+
this.file = new JsonFile({ path, fallback: EMPTY_RECORD });
|
|
1975
|
+
}
|
|
1976
|
+
load() {
|
|
1977
|
+
return { ...EMPTY_RECORD, ...this.file.read() };
|
|
1978
|
+
}
|
|
1979
|
+
/** Each mode keeps its own high score; the rest of the record is shared. */
|
|
1980
|
+
recordGame({ score, tokens, mode = "vibe" }) {
|
|
1981
|
+
const previous = this.load();
|
|
1982
|
+
const next = {
|
|
1983
|
+
highScore: mode === "vibe" ? Math.max(previous.highScore, score) : previous.highScore,
|
|
1984
|
+
proHighScore: mode === "pro" ? Math.max(previous.proHighScore, score) : previous.proHighScore,
|
|
1985
|
+
bestTokens: Math.max(previous.bestTokens, tokens),
|
|
1986
|
+
totalTokens: previous.totalTokens + tokens,
|
|
1987
|
+
gamesPlayed: previous.gamesPlayed + 1,
|
|
1988
|
+
lastScore: score,
|
|
1989
|
+
updatedAt: Date.now()
|
|
1990
|
+
};
|
|
1991
|
+
this.file.write(next);
|
|
1992
|
+
return next;
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
// src/config/constants.ts
|
|
1997
|
+
var GAME_CONSTANTS = {
|
|
1998
|
+
tickMs: 33,
|
|
1999
|
+
minColumns: 50,
|
|
2000
|
+
minRows: 14,
|
|
2001
|
+
hudRows: 1,
|
|
2002
|
+
groundMarginRows: 2,
|
|
2003
|
+
footerRows: 1,
|
|
2004
|
+
playerX: 28,
|
|
2005
|
+
playerMinX: 2,
|
|
2006
|
+
walkSpeed: 0.8,
|
|
2007
|
+
moveHoldTicks: 14,
|
|
2008
|
+
moveRepeatTicks: 6,
|
|
2009
|
+
moveRepeatWindowTicks: 10,
|
|
2010
|
+
holdHeartbeatTicks: 90,
|
|
2011
|
+
bugWalkSpeed: 0.35,
|
|
2012
|
+
ghostFlySpeed: 0.55,
|
|
2013
|
+
powerUpWalkSpeed: 0.3,
|
|
2014
|
+
rankingPanelColumn: 1,
|
|
2015
|
+
rankingPanelLimit: 10,
|
|
2016
|
+
gravity: 0.4,
|
|
2017
|
+
jumpVelocity: 5,
|
|
2018
|
+
fastFallVelocity: -3,
|
|
2019
|
+
baseSpeed: 0.9,
|
|
2020
|
+
maxSpeed: 2.6,
|
|
2021
|
+
speedGainPerColumn: 25e-5,
|
|
2022
|
+
duckTicks: 12,
|
|
2023
|
+
runFrameTicks: 5,
|
|
2024
|
+
scorePerColumn: 0.5,
|
|
2025
|
+
coinPoints: 25,
|
|
2026
|
+
minGapColumns: 28,
|
|
2027
|
+
maxGapColumns: 60,
|
|
2028
|
+
gapPerSpeed: 10,
|
|
2029
|
+
tokenSpawnChance: 0.65,
|
|
2030
|
+
tokenRowCount: 3,
|
|
2031
|
+
tokenSpacing: 5,
|
|
2032
|
+
tokenAltitudes: [9, 14, 18],
|
|
2033
|
+
tokenArc: [0, 4, 0],
|
|
2034
|
+
bugPairChance: 0.35,
|
|
2035
|
+
bugPairMinDistance: 250,
|
|
2036
|
+
pipeMinDistance: 350,
|
|
2037
|
+
ghostMinDistance: 700,
|
|
2038
|
+
ghostChance: 0.22,
|
|
2039
|
+
pipeChance: 0.45,
|
|
2040
|
+
ghostMidAltitude: 6,
|
|
2041
|
+
ghostHighAltitude: 11,
|
|
2042
|
+
squishTicks: 12,
|
|
2043
|
+
stompTolerance: 5,
|
|
2044
|
+
stompBonus: 100,
|
|
2045
|
+
bounceVelocity: 3.6,
|
|
2046
|
+
blockAltitude: 16,
|
|
2047
|
+
blockSize: 8,
|
|
2048
|
+
/** Logos drawn with sextant glyphs pack 2×3 sub-pixels per cell: a block face is 16 wide × 12 tall. */
|
|
2049
|
+
logoWidth: 16,
|
|
2050
|
+
logoHeight: 12,
|
|
2051
|
+
blockGroupChance: 0.5,
|
|
2052
|
+
/** Block group sizes and their weights; the question-block slots per size are fixed Mario-style. */
|
|
2053
|
+
blockGroupSizes: [1, 2, 3, 5],
|
|
2054
|
+
blockGroupWeights: [0.2, 0.2, 0.35, 0.25],
|
|
2055
|
+
singleBlockQuestionChance: 0.6,
|
|
2056
|
+
brickBonus: 50,
|
|
2057
|
+
debrisTicks: 30,
|
|
2058
|
+
blockHitTolerance: 4,
|
|
2059
|
+
landingTolerance: 3,
|
|
2060
|
+
powerUpBonus: 200,
|
|
2061
|
+
sponsorBonusStart: 300,
|
|
2062
|
+
sponsorBonusStep: 100,
|
|
2063
|
+
powerUpEmergeSpeed: 1,
|
|
2064
|
+
/** Lives: the run starts with three; some ? blocks pop a heart (1-UP) instead of the logo; a hit costs one and grants a short blink of invulnerability. */
|
|
2065
|
+
startLives: 3,
|
|
2066
|
+
maxLives: 9,
|
|
2067
|
+
oneUpChance: 0.3,
|
|
2068
|
+
hurtInvulnerableTicks: 60,
|
|
2069
|
+
hurtBlinkTicks: 4,
|
|
2070
|
+
floatingTextTicks: 40,
|
|
2071
|
+
floatingTextRise: 0.35,
|
|
2072
|
+
cloudInterval: 70,
|
|
2073
|
+
cloudParallax: 0.35,
|
|
2074
|
+
cloudMinAltitude: 30,
|
|
2075
|
+
cloudAltitudeSpread: 10,
|
|
2076
|
+
hillInterval: 130,
|
|
2077
|
+
hillParallax: 0.5,
|
|
2078
|
+
groundBrickRows: 4,
|
|
2079
|
+
collisionSideInset: 2,
|
|
2080
|
+
collisionTopInset: 1,
|
|
2081
|
+
claudePollTicks: 2,
|
|
2082
|
+
bannerTicks: 150,
|
|
2083
|
+
bannerBlinkTicks: 8,
|
|
2084
|
+
claudeStaleMs: 2 * 60 * 60 * 1e3
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
// src/render/build_ranking_lines.ts
|
|
2088
|
+
function buildRankingLines(view) {
|
|
2089
|
+
if (view.state === "off") return [];
|
|
2090
|
+
if (view.state === "pending") return [[{ text: "submitting score...", color: PALETTE.textDim }]];
|
|
2091
|
+
if (view.state === "failed" || view.result === null) return [[{ text: "ranking unavailable (offline?)", color: PALETTE.textDim }]];
|
|
2092
|
+
const rank = view.result.rank;
|
|
2093
|
+
return [[{ text: rank === null ? "not ranked yet" : `your global rank: #${rank}`, color: PALETTE.token, isBold: true }]];
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// src/render/build_sponsor_line.ts
|
|
2097
|
+
var SEPARATOR2 = " \xB7 ";
|
|
2098
|
+
var MAX_NAMES = 4;
|
|
2099
|
+
function buildSponsorLine(sponsors) {
|
|
2100
|
+
if (sponsors.length === 0) return [];
|
|
2101
|
+
const names = sponsors.slice(0, MAX_NAMES).flatMap((sponsor, i) => [
|
|
2102
|
+
...i === 0 ? [] : [{ text: SEPARATOR2, color: PALETTE.textDim }],
|
|
2103
|
+
{ text: sponsor.name, color: PALETTE.token, isBold: true, link: sponsor.url }
|
|
2104
|
+
]);
|
|
2105
|
+
return [{ text: "sponsored by ", color: PALETTE.textDim }, ...names];
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/render/join_segments.ts
|
|
2109
|
+
function encodeSegment(segment) {
|
|
2110
|
+
const bold = segment.isBold === true ? ANSI.bold : "";
|
|
2111
|
+
const dim = segment.isDim === true ? ANSI.dim : "";
|
|
2112
|
+
const color = segment.color === void 0 ? "" : ANSI.fg(segment.color);
|
|
2113
|
+
const styled = `${bold}${dim}${color}${segment.text}${ANSI.reset}`;
|
|
2114
|
+
return segment.link === void 0 ? styled : `${ANSI.linkOpen(segment.link)}${styled}${ANSI.linkClose}`;
|
|
2115
|
+
}
|
|
2116
|
+
function joinSegments(segments) {
|
|
2117
|
+
const text = segments.map(encodeSegment).join("");
|
|
2118
|
+
const visibleLength = segments.reduce((sum, segment) => sum + segment.text.length, 0);
|
|
2119
|
+
return { text, visibleLength };
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// src/render/fit_line.ts
|
|
2123
|
+
function computeLeftPadding({ free, align }) {
|
|
2124
|
+
if (align === "left") return 0;
|
|
2125
|
+
if (align === "right") return free;
|
|
2126
|
+
return Math.floor(free / 2);
|
|
2127
|
+
}
|
|
2128
|
+
function fitLine({ segments, columns, align = "left" }) {
|
|
2129
|
+
const joined = joinSegments(segments);
|
|
2130
|
+
if (joined.visibleLength > columns) return fitLine({ segments: [{ text: " ".repeat(columns) }], columns, align });
|
|
2131
|
+
const free = columns - joined.visibleLength;
|
|
2132
|
+
const left = computeLeftPadding({ free, align });
|
|
2133
|
+
return " ".repeat(left) + joined.text + " ".repeat(free - left);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/render/create_sprite.ts
|
|
2137
|
+
function createSprite({ rows, palette }) {
|
|
2138
|
+
const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
2139
|
+
return { rows, palette, width, height: rows.length };
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/render/sprites/pixel_font.ts
|
|
2143
|
+
var PIXEL_FONT = {
|
|
2144
|
+
S: [".####", "#....", "#....", ".###.", "....#", "....#", "####."],
|
|
2145
|
+
U: ["#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
|
|
2146
|
+
P: ["####.", "#...#", "#...#", "####.", "#....", "#....", "#...."],
|
|
2147
|
+
E: ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
|
|
2148
|
+
R: ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"],
|
|
2149
|
+
D: ["####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."],
|
|
2150
|
+
A: [".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"],
|
|
2151
|
+
I: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"],
|
|
2152
|
+
O: [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
|
|
2153
|
+
B: ["####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."],
|
|
2154
|
+
".": [".", ".", ".", ".", ".", ".", "#"],
|
|
2155
|
+
" ": [".", ".", ".", ".", ".", ".", "."]
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
// src/render/sprites/title_card.ts
|
|
2159
|
+
var LETTER_HEIGHT = 7;
|
|
2160
|
+
var LETTER_GAP = 1;
|
|
2161
|
+
var PADDING_X = 4;
|
|
2162
|
+
var PADDING_Y = 2;
|
|
2163
|
+
var LINE_GAP = 1;
|
|
2164
|
+
var SHADOW_OFFSET = 1;
|
|
2165
|
+
var PANEL = "p";
|
|
2166
|
+
var INK = "w";
|
|
2167
|
+
var SHADOW = "k";
|
|
2168
|
+
var RIVET = "r";
|
|
2169
|
+
var EMPTY = ".";
|
|
2170
|
+
function measureLine(text) {
|
|
2171
|
+
return [...text].reduce((width, char) => width + (PIXEL_FONT[char]?.[0]?.length ?? 1) + LETTER_GAP, -LETTER_GAP);
|
|
2172
|
+
}
|
|
2173
|
+
function stampLine({ grid, text, x, y }) {
|
|
2174
|
+
let cursor = x;
|
|
2175
|
+
[...text].forEach((char) => {
|
|
2176
|
+
const glyph = PIXEL_FONT[char] ?? PIXEL_FONT[" "] ?? [];
|
|
2177
|
+
glyph.forEach((row, gy) => [...row].forEach((cell, gx) => {
|
|
2178
|
+
if (cell !== "#") return;
|
|
2179
|
+
const shadowRow = grid[y + gy + SHADOW_OFFSET];
|
|
2180
|
+
if (shadowRow !== void 0) shadowRow[cursor + gx + SHADOW_OFFSET] = SHADOW;
|
|
2181
|
+
}));
|
|
2182
|
+
glyph.forEach((row, gy) => [...row].forEach((cell, gx) => {
|
|
2183
|
+
const inkRow = grid[y + gy];
|
|
2184
|
+
if (cell === "#" && inkRow !== void 0) inkRow[cursor + gx] = INK;
|
|
2185
|
+
}));
|
|
2186
|
+
cursor += (glyph[0]?.length ?? 1) + LETTER_GAP;
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
function createTitleCard(lines) {
|
|
2190
|
+
const textWidth = Math.max(...lines.map(measureLine));
|
|
2191
|
+
const width = textWidth + PADDING_X * 2 + SHADOW_OFFSET;
|
|
2192
|
+
const height = PADDING_Y * 2 + lines.length * LETTER_HEIGHT + (lines.length - 1) * LINE_GAP + SHADOW_OFFSET;
|
|
2193
|
+
const grid = Array.from({ length: height }, () => Array(width).fill(PANEL));
|
|
2194
|
+
[[0, 0], [0, width - 1], [height - 1, 0], [height - 1, width - 1]].forEach(([y, x]) => {
|
|
2195
|
+
if (y !== void 0 && x !== void 0) grid[y][x] = EMPTY;
|
|
2196
|
+
});
|
|
2197
|
+
[[1, 1], [1, width - 2], [height - 2, 1], [height - 2, width - 2]].forEach(([y, x]) => {
|
|
2198
|
+
if (y !== void 0 && x !== void 0) grid[y][x] = RIVET;
|
|
2199
|
+
});
|
|
2200
|
+
lines.forEach((text, i) => stampLine({ grid, text, x: PADDING_X, y: PADDING_Y + i * (LETTER_HEIGHT + LINE_GAP) }));
|
|
2201
|
+
return createSprite({
|
|
2202
|
+
rows: grid.map((row) => row.join("")),
|
|
2203
|
+
palette: { [PANEL]: PALETTE.titlePanel, [INK]: PALETTE.titleInk, [SHADOW]: PALETTE.titleShadow, [RIVET]: PALETTE.titleInk }
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
var TITLE_CARD = createTitleCard(["SUPER", "DARIO BROS."]);
|
|
2207
|
+
var TITLE_CARD_TOP_ROW = 2;
|
|
2208
|
+
var TITLE_CARD_ROWS = Math.ceil(TITLE_CARD.height / 2);
|
|
2209
|
+
|
|
2210
|
+
// src/player/agent_labels.ts
|
|
2211
|
+
var AGENT_LABELS = { claude: "CLAUDE", codex: "CODEX", grok: "GROK" };
|
|
2212
|
+
|
|
2213
|
+
// src/render/build_overlay.ts
|
|
2214
|
+
var BANNER_ROW = 0;
|
|
2215
|
+
var TITLE_OFFSET = -3;
|
|
2216
|
+
var SETTINGS_LABEL_WIDTH = 24;
|
|
2217
|
+
function buildSettingsLines(view) {
|
|
2218
|
+
const rows = view.items.map((item, i) => {
|
|
2219
|
+
const isSelected = i === view.selected;
|
|
2220
|
+
const marker = isSelected ? "> " : " ";
|
|
2221
|
+
return [{ text: `${marker}${item.label.padEnd(SETTINGS_LABEL_WIDTH)}${isSelected ? "< " : " "}${item.value}${isSelected ? " >" : " "}`, color: isSelected ? PALETTE.claudeOrange : PALETTE.text, isBold: isSelected }];
|
|
2222
|
+
});
|
|
2223
|
+
return [[{ text: "SETTINGS", color: PALETTE.token, isBold: true }], [], ...rows, [], [{ text: "\u2191 \u2193 pick \u2190 \u2192 / SPACE change C close", color: PALETTE.textDim }]];
|
|
2224
|
+
}
|
|
2225
|
+
function describeMatchup(agent) {
|
|
2226
|
+
const rivals = Object.keys(AGENT_LABELS).filter((kind) => kind !== agent).map((kind) => AGENT_LABELS[kind]).join(" & ");
|
|
2227
|
+
return `you are ${AGENT_LABELS[agent]} \xB7 stomp ${rivals}`;
|
|
2228
|
+
}
|
|
2229
|
+
function withSponsorLine({ lines, sponsors }) {
|
|
2230
|
+
const sponsorLine = buildSponsorLine(sponsors);
|
|
2231
|
+
return sponsorLine.length === 0 ? lines : [...lines, [], sponsorLine];
|
|
2232
|
+
}
|
|
2233
|
+
function buildHint({ hint, lockMessage }) {
|
|
2234
|
+
if (lockMessage === null) return [{ text: hint, color: PALETTE.textDim }];
|
|
2235
|
+
return [{ text: lockMessage, color: PALETTE.warning, isBold: true }];
|
|
2236
|
+
}
|
|
2237
|
+
function buildPhaseLines({ phase, score, highScore, ranking, sponsors, agent, lockMessage }) {
|
|
2238
|
+
if (phase === "ready") {
|
|
2239
|
+
return withSponsorLine({ sponsors, lines: [
|
|
2240
|
+
[{ text: "\xA92026 EVENTUALLY SOLUTIONS", color: PALETTE.titleInk }],
|
|
2241
|
+
[{ text: describeMatchup(agent), color: PALETTE.text }],
|
|
2242
|
+
buildHint({ hint: "press SPACE to start", lockMessage })
|
|
2243
|
+
] });
|
|
2244
|
+
}
|
|
2245
|
+
if (phase === "paused") return [[{ text: "PAUSED", color: PALETTE.warning, isBold: true }], buildHint({ hint: "press P or SPACE to resume", lockMessage })];
|
|
2246
|
+
if (phase === "over") {
|
|
2247
|
+
return withSponsorLine({ sponsors, lines: [
|
|
2248
|
+
[{ text: "GAME OVER", color: PALETTE.danger, isBold: true }],
|
|
2249
|
+
[{ text: `score ${score} best ${highScore}`, color: PALETTE.text }],
|
|
2250
|
+
...buildRankingLines(ranking),
|
|
2251
|
+
buildHint({ hint: "R play again Q quit", lockMessage })
|
|
2252
|
+
] });
|
|
2253
|
+
}
|
|
2254
|
+
return [];
|
|
2255
|
+
}
|
|
2256
|
+
function isBannerVisible({ banner, tickCount }) {
|
|
2257
|
+
if (banner === null || banner.ticksLeft <= 0) return false;
|
|
2258
|
+
const isSteadyPhase = banner.ticksLeft > GAME_CONSTANTS.bannerTicks / 2;
|
|
2259
|
+
const isBlinkOn = Math.floor(tickCount / GAME_CONSTANTS.bannerBlinkTicks) % 2 === 0;
|
|
2260
|
+
return isSteadyPhase || isBlinkOn;
|
|
2261
|
+
}
|
|
2262
|
+
function buildOverlay(input) {
|
|
2263
|
+
const lines = /* @__PURE__ */ new Map();
|
|
2264
|
+
const isTitleScreen = input.phase === "ready" && input.settings === null;
|
|
2265
|
+
const naturalRow = isTitleScreen ? TITLE_CARD_TOP_ROW + TITLE_CARD_ROWS : input.settings !== null ? TITLE_CARD_TOP_ROW : Math.max(0, Math.floor(input.layout.playRows / 2) + TITLE_OFFSET);
|
|
2266
|
+
const startRow = Math.max(naturalRow, input.minTextRow ?? 0);
|
|
2267
|
+
const phaseLines = input.settings === null ? buildPhaseLines(input) : buildSettingsLines(input.settings);
|
|
2268
|
+
phaseLines.forEach((segments, i) => lines.set(startRow + i, segments));
|
|
2269
|
+
if (isBannerVisible(input) && input.banner !== null) lines.set(BANNER_ROW, [{ text: ` ${input.banner.text} `, color: input.banner.color, isBold: true }]);
|
|
2270
|
+
return renderLines({ lines, columns: input.layout.columns });
|
|
2271
|
+
}
|
|
2272
|
+
function renderLines({ lines, columns }) {
|
|
2273
|
+
const rendered = /* @__PURE__ */ new Map();
|
|
2274
|
+
lines.forEach((segments, row) => rendered.set(row, fitLine({ segments, columns, align: "center" })));
|
|
2275
|
+
return rendered;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// src/render/compute_layout.ts
|
|
2279
|
+
var PIXELS_PER_ROW = 2;
|
|
2280
|
+
function computeLayout(size) {
|
|
2281
|
+
const columns = Math.max(size.columns, GAME_CONSTANTS.minColumns);
|
|
2282
|
+
const rows = Math.max(size.rows, GAME_CONSTANTS.minRows);
|
|
2283
|
+
const playRows = rows - GAME_CONSTANTS.hudRows - GAME_CONSTANTS.footerRows;
|
|
2284
|
+
const pixelHeight = playRows * PIXELS_PER_ROW;
|
|
2285
|
+
const groundY = pixelHeight - GAME_CONSTANTS.groundMarginRows * PIXELS_PER_ROW;
|
|
2286
|
+
return { columns, rows, playRows, pixelHeight, groundY };
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/render/draw_ranking_panel.ts
|
|
2290
|
+
var NAME_WIDTH = 12;
|
|
2291
|
+
var SCORE_WIDTH = 6;
|
|
2292
|
+
var RANK_WIDTH = 3;
|
|
2293
|
+
var HEADERS = { vibe: "RANKINGS", pro: "PRO RANKINGS" };
|
|
2294
|
+
var GAP_LINE = "...";
|
|
2295
|
+
var PANEL_TOP_ROW = 1;
|
|
2296
|
+
var SPONSOR_PREFIX = " \xB7 sponsored by ";
|
|
2297
|
+
function formatEntry({ entry, index, uid }) {
|
|
2298
|
+
const rank = `#${index + 1}`.padEnd(RANK_WIDTH);
|
|
2299
|
+
const name = entry.name.slice(0, NAME_WIDTH).padEnd(NAME_WIDTH);
|
|
2300
|
+
const isMe = entry.uid === uid;
|
|
2301
|
+
return { text: `${rank} ${name} ${String(entry.highScore).padStart(SCORE_WIDTH)}`, color: isMe ? PALETTE.claudeOrange : PALETTE.text };
|
|
2302
|
+
}
|
|
2303
|
+
function buildHeader({ sponsor, mode }) {
|
|
2304
|
+
const header = { text: HEADERS[mode], color: mode === "pro" ? PALETTE.warning : PALETTE.textDim };
|
|
2305
|
+
if (sponsor === null) return [header];
|
|
2306
|
+
return [header, { text: SPONSOR_PREFIX, color: PALETTE.textDim }, { text: sponsor.name.toUpperCase(), color: PALETTE.token, link: sponsor.url }];
|
|
2307
|
+
}
|
|
2308
|
+
function buildMyLine({ view, me }) {
|
|
2309
|
+
const rank = view.result?.rank ?? null;
|
|
2310
|
+
if (me === null || rank === null || rank <= (view.result?.top.length ?? 0)) return [];
|
|
2311
|
+
const rankText = `#${rank}`.padEnd(RANK_WIDTH);
|
|
2312
|
+
return [{ text: GAP_LINE, color: PALETTE.textDim }, { text: `${rankText} ${me.name.slice(0, NAME_WIDTH).padEnd(NAME_WIDTH)} ${String(me.score).padStart(SCORE_WIDTH)}`, color: PALETTE.claudeOrange }];
|
|
2313
|
+
}
|
|
2314
|
+
function buildLines({ view, me }) {
|
|
2315
|
+
if (view.result === null) return [{ text: view.state === "failed" ? "offline" : "loading...", color: PALETTE.textDim }];
|
|
2316
|
+
const top = view.result.top.map((entry, index) => formatEntry({ entry, index, uid: view.result?.uid ?? "" }));
|
|
2317
|
+
return [...top, ...buildMyLine({ view, me })];
|
|
2318
|
+
}
|
|
2319
|
+
function drawRankingPanel({ buffer, view, mode = "vibe", sponsor = null, me = null }) {
|
|
2320
|
+
if (view.state === "off") return;
|
|
2321
|
+
let column = GAME_CONSTANTS.rankingPanelColumn;
|
|
2322
|
+
buildHeader({ sponsor, mode }).forEach((segment) => {
|
|
2323
|
+
buffer.setText({ column, row: PANEL_TOP_ROW, text: segment.text, color: segment.color, link: segment.link ?? null });
|
|
2324
|
+
column += segment.text.length;
|
|
2325
|
+
});
|
|
2326
|
+
buildLines({ view, me }).forEach((line, index) => buffer.setText({ column: GAME_CONSTANTS.rankingPanelColumn, row: PANEL_TOP_ROW + index + 1, text: line.text, color: line.color }));
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// src/render/draw_text_face.ts
|
|
2330
|
+
var PIXELS_PER_ROW2 = 2;
|
|
2331
|
+
function drawTextFace({ buffer, face, x, y, link = null }) {
|
|
2332
|
+
const size = GAME_CONSTANTS.blockSize;
|
|
2333
|
+
const column = Math.round(x);
|
|
2334
|
+
const topRow = Math.round(y / PIXELS_PER_ROW2);
|
|
2335
|
+
const rows = size / PIXELS_PER_ROW2;
|
|
2336
|
+
const firstTextRow = topRow + Math.floor((rows - face.lines.length) / 2);
|
|
2337
|
+
for (let row = topRow; row < topRow + rows; row++) {
|
|
2338
|
+
const line = face.lines[row - firstTextRow];
|
|
2339
|
+
const text = (line ?? "").slice(0, size);
|
|
2340
|
+
const pad = Math.floor((size - text.length) / 2);
|
|
2341
|
+
buffer.setText({ column, row, text: " ".repeat(pad) + text + " ".repeat(size - pad - text.length), color: face.fg, bg: face.bg, link });
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/render/is_same_color.ts
|
|
2346
|
+
function isSameColor(a, b) {
|
|
2347
|
+
if (a === b) return true;
|
|
2348
|
+
if (a === null || b === null) return false;
|
|
2349
|
+
return a.r === b.r && a.g === b.g && a.b === b.b;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// src/render/ansi_row_encoder.ts
|
|
2353
|
+
var AnsiRowEncoder = class {
|
|
2354
|
+
parts = [];
|
|
2355
|
+
currentFg = null;
|
|
2356
|
+
currentBg = null;
|
|
2357
|
+
currentLink = null;
|
|
2358
|
+
push(cell) {
|
|
2359
|
+
if (cell.link !== this.currentLink) this.setLink(cell.link);
|
|
2360
|
+
if (this.needsReset(cell)) this.reset();
|
|
2361
|
+
if (cell.fg !== null && !isSameColor(cell.fg, this.currentFg)) this.setForeground(cell.fg);
|
|
2362
|
+
if (cell.bg !== null && !isSameColor(cell.bg, this.currentBg)) this.setBackground(cell.bg);
|
|
2363
|
+
this.parts.push(cell.glyph);
|
|
2364
|
+
}
|
|
2365
|
+
finish() {
|
|
2366
|
+
if (this.currentLink !== null) this.setLink(null);
|
|
2367
|
+
if (this.currentFg !== null || this.currentBg !== null) this.reset();
|
|
2368
|
+
return this.parts.join("");
|
|
2369
|
+
}
|
|
2370
|
+
needsReset(cell) {
|
|
2371
|
+
const dropsFg = cell.fg === null && this.currentFg !== null;
|
|
2372
|
+
const dropsBg = cell.bg === null && this.currentBg !== null;
|
|
2373
|
+
return dropsFg || dropsBg;
|
|
2374
|
+
}
|
|
2375
|
+
reset() {
|
|
2376
|
+
this.parts.push(ANSI.reset);
|
|
2377
|
+
this.currentFg = null;
|
|
2378
|
+
this.currentBg = null;
|
|
2379
|
+
}
|
|
2380
|
+
setForeground(color) {
|
|
2381
|
+
this.parts.push(ANSI.fg(color));
|
|
2382
|
+
this.currentFg = color;
|
|
2383
|
+
}
|
|
2384
|
+
setBackground(color) {
|
|
2385
|
+
this.parts.push(ANSI.bg(color));
|
|
2386
|
+
this.currentBg = color;
|
|
2387
|
+
}
|
|
2388
|
+
setLink(link) {
|
|
2389
|
+
this.parts.push(link === null ? ANSI.linkClose : ANSI.linkOpen(link));
|
|
2390
|
+
this.currentLink = link;
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
// src/render/encode_pixel_cell.ts
|
|
2395
|
+
var UPPER_HALF = "\u2580";
|
|
2396
|
+
var LOWER_HALF = "\u2584";
|
|
2397
|
+
var FULL_BLOCK = "\u2588";
|
|
2398
|
+
function encodePixelCell({ top, bottom, link = null }) {
|
|
2399
|
+
if (top === null && bottom === null) return { glyph: " ", fg: null, bg: null, link: null };
|
|
2400
|
+
if (top === null) return { glyph: LOWER_HALF, fg: bottom, bg: null, link };
|
|
2401
|
+
if (bottom === null) return { glyph: UPPER_HALF, fg: top, bg: null, link };
|
|
2402
|
+
if (isSameColor(top, bottom)) return { glyph: FULL_BLOCK, fg: top, bg: null, link };
|
|
2403
|
+
return { glyph: UPPER_HALF, fg: top, bg: bottom, link };
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// src/render/encode_dense_cell.ts
|
|
2407
|
+
var DENSE_COLUMNS = 2;
|
|
2408
|
+
var DENSE_ROWS = 3;
|
|
2409
|
+
var SLOT_COUNT = DENSE_COLUMNS * DENSE_ROWS;
|
|
2410
|
+
var SEXTANT_BASE = 129792;
|
|
2411
|
+
var LEFT_HALF_MASK = 21;
|
|
2412
|
+
var RIGHT_HALF_MASK = 42;
|
|
2413
|
+
var FULL_MASK = 63;
|
|
2414
|
+
function sextantGlyph(mask) {
|
|
2415
|
+
if (mask === 0) return " ";
|
|
2416
|
+
if (mask === LEFT_HALF_MASK) return "\u258C";
|
|
2417
|
+
if (mask === RIGHT_HALF_MASK) return "\u2590";
|
|
2418
|
+
if (mask === FULL_MASK) return "\u2588";
|
|
2419
|
+
const skipped = (mask > LEFT_HALF_MASK ? 1 : 0) + (mask > RIGHT_HALF_MASK ? 1 : 0);
|
|
2420
|
+
return String.fromCodePoint(SEXTANT_BASE + mask - 1 - skipped);
|
|
2421
|
+
}
|
|
2422
|
+
function distance(a, b) {
|
|
2423
|
+
return (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
|
|
2424
|
+
}
|
|
2425
|
+
function rankColors(colors) {
|
|
2426
|
+
const ranked = [];
|
|
2427
|
+
colors.forEach((color) => {
|
|
2428
|
+
if (color === null) return;
|
|
2429
|
+
const found = ranked.find((entry) => isSameColor(entry.color, color));
|
|
2430
|
+
if (found === void 0) ranked.push({ color, count: 1 });
|
|
2431
|
+
else found.count++;
|
|
2432
|
+
});
|
|
2433
|
+
return ranked.sort((a, b) => b.count - a.count).map((entry) => entry.color);
|
|
2434
|
+
}
|
|
2435
|
+
function buildMask(colors, isOn) {
|
|
2436
|
+
return colors.slice(0, SLOT_COUNT).reduce((mask, color, i) => color !== null && isOn(color) ? mask | 1 << i : mask, 0);
|
|
2437
|
+
}
|
|
2438
|
+
function encodeDenseCell({ colors, link = null }) {
|
|
2439
|
+
const [primary, secondary] = rankColors(colors);
|
|
2440
|
+
if (primary === void 0) return { glyph: " ", fg: null, bg: null, link: null };
|
|
2441
|
+
const hasTransparent = colors.slice(0, SLOT_COUNT).some((color) => color === null);
|
|
2442
|
+
if (secondary === void 0 || hasTransparent) return { glyph: sextantGlyph(buildMask(colors, () => true)), fg: primary, bg: null, link };
|
|
2443
|
+
const mask = buildMask(colors, (color) => distance(color, primary) <= distance(color, secondary));
|
|
2444
|
+
return { glyph: sextantGlyph(mask), fg: primary, bg: secondary, link };
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// src/render/pixel_buffer.ts
|
|
2448
|
+
var TRANSPARENT_CHAR = ".";
|
|
2449
|
+
var PixelBuffer = class {
|
|
2450
|
+
width;
|
|
2451
|
+
height;
|
|
2452
|
+
pixels;
|
|
2453
|
+
links;
|
|
2454
|
+
/** Text layer keyed by terminal cell (row * width + column); text always wins over pixels. */
|
|
2455
|
+
textCells = /* @__PURE__ */ new Map();
|
|
2456
|
+
/** High-density layer: 2×3 sub-pixels per cell, drawn with sextant glyphs (sits above pixels, below text). */
|
|
2457
|
+
denseCells = /* @__PURE__ */ new Map();
|
|
2458
|
+
constructor({ width, height }) {
|
|
2459
|
+
this.width = width;
|
|
2460
|
+
this.height = height;
|
|
2461
|
+
this.pixels = new Array(width * height).fill(null);
|
|
2462
|
+
this.links = new Array(width * height).fill(null);
|
|
2463
|
+
}
|
|
2464
|
+
clear() {
|
|
2465
|
+
this.pixels.fill(null);
|
|
2466
|
+
this.links.fill(null);
|
|
2467
|
+
this.textCells.clear();
|
|
2468
|
+
this.denseCells.clear();
|
|
2469
|
+
}
|
|
2470
|
+
setPixel({ x, y, color, link = null }) {
|
|
2471
|
+
if (!this.isInside({ x, y })) return;
|
|
2472
|
+
this.pixels[y * this.width + x] = color;
|
|
2473
|
+
this.links[y * this.width + x] = link;
|
|
2474
|
+
}
|
|
2475
|
+
getPixel({ x, y }) {
|
|
2476
|
+
if (!this.isInside({ x, y })) return null;
|
|
2477
|
+
return this.pixels[y * this.width + x] ?? null;
|
|
2478
|
+
}
|
|
2479
|
+
setText({ column, row, text, color, bg = null, link = null }) {
|
|
2480
|
+
const rowCount = Math.floor(this.height / 2);
|
|
2481
|
+
if (row < 0 || row >= rowCount) return;
|
|
2482
|
+
[...text].forEach((glyph, i) => {
|
|
2483
|
+
const x = column + i;
|
|
2484
|
+
if (x >= 0 && x < this.width) this.textCells.set(row * this.width + x, { glyph, fg: color, bg, link });
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Draws a sprite at sextant density: two sprite columns per terminal column, three sprite rows per
|
|
2489
|
+
* terminal row. `x` is in terminal columns, `y` in pixel rows (rounded to a terminal row).
|
|
2490
|
+
*/
|
|
2491
|
+
drawDenseSprite({ sprite, x, y, link = null }) {
|
|
2492
|
+
const originColumn = Math.round(x);
|
|
2493
|
+
const originRow = Math.round(y / 2);
|
|
2494
|
+
sprite.rows.forEach((row, sy) => {
|
|
2495
|
+
[...row].forEach((char, sx) => {
|
|
2496
|
+
const color = char === TRANSPARENT_CHAR ? void 0 : sprite.palette[char];
|
|
2497
|
+
const slot = sy % DENSE_ROWS * DENSE_COLUMNS + sx % DENSE_COLUMNS;
|
|
2498
|
+
if (color !== void 0) this.setDensePixel({ column: originColumn + Math.floor(sx / DENSE_COLUMNS), row: originRow + Math.floor(sy / DENSE_ROWS), slot, color, link });
|
|
2499
|
+
});
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
drawSprite({ sprite, x, y, link = null }) {
|
|
2503
|
+
const originX = Math.round(x);
|
|
2504
|
+
const originY = Math.round(y);
|
|
2505
|
+
sprite.rows.forEach((row, rowIndex) => this.drawSpriteRow({ sprite, row, x: originX, y: originY + rowIndex, link }));
|
|
2506
|
+
}
|
|
2507
|
+
toAnsiRows() {
|
|
2508
|
+
const rowCount = Math.floor(this.height / 2);
|
|
2509
|
+
return Array.from({ length: rowCount }, (_, row) => this.encodeRow(row));
|
|
2510
|
+
}
|
|
2511
|
+
setDensePixel({ column, row, slot, color, link }) {
|
|
2512
|
+
const rowCount = Math.floor(this.height / 2);
|
|
2513
|
+
if (column < 0 || column >= this.width || row < 0 || row >= rowCount) return;
|
|
2514
|
+
const key = row * this.width + column;
|
|
2515
|
+
const cell = this.denseCells.get(key) ?? { colors: new Array(DENSE_COLUMNS * DENSE_ROWS).fill(null), link };
|
|
2516
|
+
cell.colors[slot] = color;
|
|
2517
|
+
this.denseCells.set(key, { colors: cell.colors, link: link ?? cell.link });
|
|
2518
|
+
}
|
|
2519
|
+
drawSpriteRow({ sprite, row, x, y, link }) {
|
|
2520
|
+
for (let i = 0; i < row.length; i++) {
|
|
2521
|
+
const char = row.charAt(i);
|
|
2522
|
+
if (char === TRANSPARENT_CHAR) continue;
|
|
2523
|
+
const color = sprite.palette[char];
|
|
2524
|
+
if (color !== void 0) this.setPixel({ x: x + i, y, color, link });
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
encodeRow(row) {
|
|
2528
|
+
const encoder = new AnsiRowEncoder();
|
|
2529
|
+
for (let x = 0; x < this.width; x++) {
|
|
2530
|
+
const text = this.textCells.get(row * this.width + x);
|
|
2531
|
+
const dense = this.denseCells.get(row * this.width + x);
|
|
2532
|
+
if (text === void 0 && dense !== void 0) {
|
|
2533
|
+
encoder.push(encodeDenseCell({ colors: dense.colors, link: dense.link }));
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
const topIndex = row * 2 * this.width + x;
|
|
2537
|
+
const bottomIndex = topIndex + this.width;
|
|
2538
|
+
const link = this.links[topIndex] ?? this.links[bottomIndex] ?? null;
|
|
2539
|
+
encoder.push(text ?? encodePixelCell({ top: this.pixels[topIndex] ?? null, bottom: this.pixels[bottomIndex] ?? null, link }));
|
|
2540
|
+
}
|
|
2541
|
+
return encoder.finish();
|
|
2542
|
+
}
|
|
2543
|
+
isInside({ x, y }) {
|
|
2544
|
+
return x >= 0 && y >= 0 && x < this.width && y < this.height;
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
// src/render/render_footer.ts
|
|
2549
|
+
var LABEL = "CONTROLS";
|
|
2550
|
+
var GAP = " ";
|
|
2551
|
+
var ARROWS = "\u2190 \u2192 \u2191 \u2193";
|
|
2552
|
+
var CONTROLS_BY_PHASE = {
|
|
2553
|
+
ready: [[ARROWS, ""], ["SPACE", "jump"], ["C", "settings"], ["Q", "quit"]],
|
|
2554
|
+
running: [[ARROWS, ""], ["SPACE", "jump"], ["P", "pause"], ["Q", "quit"]],
|
|
2555
|
+
paused: [["P / SPACE", "resume"], ["C", "settings"], ["R", "restart"], ["Q", "quit"]],
|
|
2556
|
+
over: [["R / SPACE", "play again"], ["Q", "quit"]]
|
|
2557
|
+
};
|
|
2558
|
+
function toSegments(phase) {
|
|
2559
|
+
const pairs = CONTROLS_BY_PHASE[phase];
|
|
2560
|
+
const keys = pairs.flatMap(([key, action], i) => [
|
|
2561
|
+
{ text: i === 0 ? "" : GAP },
|
|
2562
|
+
{ text: key, color: PALETTE.text, isBold: true },
|
|
2563
|
+
{ text: action.length === 0 ? "" : ` ${action}`, color: PALETTE.textDim }
|
|
2564
|
+
]);
|
|
2565
|
+
const hasArrows = pairs[0]?.[0] === ARROWS;
|
|
2566
|
+
return hasArrows ? [{ text: LABEL, color: PALETTE.claudeOrange, isBold: true }, { text: GAP }, ...keys] : keys;
|
|
2567
|
+
}
|
|
2568
|
+
function renderFooter({ columns, phase }) {
|
|
2569
|
+
return fitLine({ segments: toSegments(phase), columns, align: "center" });
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// src/render/thinking_verbs.ts
|
|
2573
|
+
var THINKING_VERBS = [
|
|
2574
|
+
"Thinking",
|
|
2575
|
+
"Considering",
|
|
2576
|
+
"Pondering",
|
|
2577
|
+
"Synthesizing",
|
|
2578
|
+
"Forging",
|
|
2579
|
+
"Herding",
|
|
2580
|
+
"Brewing",
|
|
2581
|
+
"Cogitating",
|
|
2582
|
+
"Musing",
|
|
2583
|
+
"Scheming",
|
|
2584
|
+
"Percolating",
|
|
2585
|
+
"Marinating",
|
|
2586
|
+
"Noodling",
|
|
2587
|
+
"Simmering",
|
|
2588
|
+
"Crunching",
|
|
2589
|
+
"Wrangling",
|
|
2590
|
+
"Ruminating",
|
|
2591
|
+
"Deliberating",
|
|
2592
|
+
"Composing",
|
|
2593
|
+
"Tinkering",
|
|
2594
|
+
"Untangling",
|
|
2595
|
+
"Weaving",
|
|
2596
|
+
"Puzzling",
|
|
2597
|
+
"Ideating"
|
|
2598
|
+
];
|
|
2599
|
+
|
|
2600
|
+
// src/render/describe_claude_status.ts
|
|
2601
|
+
var VERB_TICKS = 60;
|
|
2602
|
+
var DOT_TICKS = 10;
|
|
2603
|
+
var MAX_DOTS = 3;
|
|
2604
|
+
var STATUS_VIEWS = {
|
|
2605
|
+
idle: { label: "idle", color: PALETTE.textDim },
|
|
2606
|
+
working: { label: "working...", color: PALETTE.working },
|
|
2607
|
+
done: { label: "DONE!", color: PALETTE.success },
|
|
2608
|
+
attention: { label: "NEEDS YOU!", color: PALETTE.warning }
|
|
2609
|
+
};
|
|
2610
|
+
function describeWorking({ record, tickCount }) {
|
|
2611
|
+
if (record.message.length > 0) return record.message;
|
|
2612
|
+
const verb = THINKING_VERBS[Math.floor(record.updatedAt / 1e3 + tickCount / VERB_TICKS) % THINKING_VERBS.length] ?? "Thinking";
|
|
2613
|
+
const dots = Math.floor(tickCount / DOT_TICKS) % (MAX_DOTS + 1);
|
|
2614
|
+
return `${verb}${".".repeat(dots)}${" ".repeat(MAX_DOTS - dots)}`;
|
|
2615
|
+
}
|
|
2616
|
+
function describeClaudeStatus({ record, now = Date.now(), tickCount = 0 }) {
|
|
2617
|
+
if (record === null) return STATUS_VIEWS.idle;
|
|
2618
|
+
const isStale = now - record.updatedAt > GAME_CONSTANTS.claudeStaleMs;
|
|
2619
|
+
if (isStale) return STATUS_VIEWS.idle;
|
|
2620
|
+
if (record.status === "working") return { label: describeWorking({ record, tickCount }), color: PALETTE.working };
|
|
2621
|
+
return STATUS_VIEWS[record.status] ?? STATUS_VIEWS.idle;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// src/render/render_hud.ts
|
|
2625
|
+
var SCORE_DIGITS = 5;
|
|
2626
|
+
var HEART = "\u2665";
|
|
2627
|
+
var LOST_HEART = "\xB7";
|
|
2628
|
+
function renderHearts(lives) {
|
|
2629
|
+
const lost = Math.min(GAME_CONSTANTS.startLives, Math.max(0, GAME_CONSTANTS.startLives - lives));
|
|
2630
|
+
return [
|
|
2631
|
+
{ text: ` ${HEART.repeat(Math.max(0, lives))}`, color: PALETTE.mushroomRed, isBold: true },
|
|
2632
|
+
{ text: LOST_HEART.repeat(lost), color: PALETTE.textDim }
|
|
2633
|
+
];
|
|
2634
|
+
}
|
|
2635
|
+
function formatScore(value) {
|
|
2636
|
+
return String(Math.floor(value)).padStart(SCORE_DIGITS, "0");
|
|
2637
|
+
}
|
|
2638
|
+
function renderHud({ columns, playerName, score, highScore, lives, mode, claude, tickCount }) {
|
|
2639
|
+
const status = describeClaudeStatus({ record: claude, tickCount });
|
|
2640
|
+
const leftSegments = [
|
|
2641
|
+
{ text: " SUPER DARIO ", color: PALETTE.claudeOrange, isBold: true },
|
|
2642
|
+
{ text: ` ${playerName}`, color: PALETTE.text },
|
|
2643
|
+
{ text: " SCORE ", color: PALETTE.textDim },
|
|
2644
|
+
{ text: formatScore(score), color: PALETTE.text, isBold: true },
|
|
2645
|
+
{ text: " HI ", color: PALETTE.textDim },
|
|
2646
|
+
{ text: formatScore(highScore), color: PALETTE.text },
|
|
2647
|
+
{ text: " LIVES", color: PALETTE.textDim },
|
|
2648
|
+
...renderHearts(lives),
|
|
2649
|
+
...mode === "pro" ? [{ text: ` ${GAME_MODE_LABELS.pro}`, color: PALETTE.warning, isBold: true }] : []
|
|
2650
|
+
];
|
|
2651
|
+
const rightSegments = [
|
|
2652
|
+
{ text: `${AGENT_LABELS[claude?.agent ?? "claude"] ?? AGENT_LABELS.claude}: `, color: PALETTE.textDim },
|
|
2653
|
+
{ text: `${status.label} `, color: status.color, isBold: true }
|
|
2654
|
+
];
|
|
2655
|
+
const left = joinSegments(leftSegments);
|
|
2656
|
+
const right = joinSegments(rightSegments);
|
|
2657
|
+
const gap = columns - left.visibleLength - right.visibleLength;
|
|
2658
|
+
if (gap < 1) return left.text + " ".repeat(Math.max(0, columns - left.visibleLength));
|
|
2659
|
+
return left.text + " ".repeat(gap) + right.text;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// src/sponsors/to_sponsor_sprite.ts
|
|
2663
|
+
var HEX_RADIX = 16;
|
|
2664
|
+
function hexToRgb(hex) {
|
|
2665
|
+
return {
|
|
2666
|
+
r: Number.parseInt(hex.slice(1, 3), HEX_RADIX),
|
|
2667
|
+
g: Number.parseInt(hex.slice(3, 5), HEX_RADIX),
|
|
2668
|
+
b: Number.parseInt(hex.slice(5, 7), HEX_RADIX)
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
function toSponsorSprite(sponsor) {
|
|
2672
|
+
const palette = Object.fromEntries(Object.entries(sponsor.palette).map(([key, hex]) => [key, hexToRgb(hex)]));
|
|
2673
|
+
return createSprite({ rows: sponsor.rows, palette });
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// src/sponsors/find_day_sponsor.ts
|
|
2677
|
+
function toUtcDay(timestamp) {
|
|
2678
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
2679
|
+
}
|
|
2680
|
+
function findDaySponsor({ sponsors, tier, now = Date.now() }) {
|
|
2681
|
+
const today = toUtcDay(now);
|
|
2682
|
+
const candidates = sponsors.filter((sponsor) => sponsor.tier === tier);
|
|
2683
|
+
return candidates.find((sponsor) => sponsor.days.includes(today)) ?? candidates.find((sponsor) => sponsor.days.length === 0) ?? null;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// src/render/is_hi_res_logo.ts
|
|
2687
|
+
function isHiResLogo(sprite) {
|
|
2688
|
+
return sprite.width === GAME_CONSTANTS.logoWidth && sprite.height === GAME_CONSTANTS.logoHeight;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// src/render/game_renderer.ts
|
|
2692
|
+
var BRICK_WIDTH = 8;
|
|
2693
|
+
var BANNER_GAP = 3;
|
|
2694
|
+
var BANNER_PHASES = /* @__PURE__ */ new Set(["ready", "running", "paused", "over"]);
|
|
2695
|
+
var TITLE_PINNED_PHASES = /* @__PURE__ */ new Set(["ready", "running"]);
|
|
2696
|
+
var BANNER_LABEL_ROWS = 2;
|
|
2697
|
+
var MENU_TEXT_ROWS = 4;
|
|
2698
|
+
var PIXELS_PER_ROW3 = 2;
|
|
2699
|
+
var GameRenderer = class {
|
|
2700
|
+
layout;
|
|
2701
|
+
buffer;
|
|
2702
|
+
constructor({ size }) {
|
|
2703
|
+
this.layout = computeLayout(size);
|
|
2704
|
+
this.buffer = new PixelBuffer({ width: this.layout.columns, height: this.layout.pixelHeight });
|
|
2705
|
+
}
|
|
2706
|
+
getLayout() {
|
|
2707
|
+
return this.layout;
|
|
2708
|
+
}
|
|
2709
|
+
resize({ size }) {
|
|
2710
|
+
this.layout = computeLayout(size);
|
|
2711
|
+
this.buffer = new PixelBuffer({ width: this.layout.columns, height: this.layout.pixelHeight });
|
|
2712
|
+
}
|
|
2713
|
+
render(frame) {
|
|
2714
|
+
this.buffer.clear();
|
|
2715
|
+
this.drawWorld(frame.world);
|
|
2716
|
+
if (frame.settings === null) this.drawTitleCard(frame.world.getTitleX());
|
|
2717
|
+
const minTextRow = frame.settings === null ? this.drawSponsorBanner(frame) : 0;
|
|
2718
|
+
const myScore = frame.mode === "pro" ? frame.scores.proHighScore : frame.scores.highScore;
|
|
2719
|
+
drawRankingPanel({ buffer: this.buffer, view: frame.ranking, mode: frame.mode, sponsor: findDaySponsor({ sponsors: frame.sponsors, tier: "ranking" }), me: { name: frame.playerName, score: myScore } });
|
|
2720
|
+
const overlay = buildOverlay({ layout: this.layout, phase: frame.phase, score: frame.world.score, tokens: frame.world.getTokensCollected(), highScore: frame.scores.highScore, banner: frame.banner, ranking: frame.ranking, sponsors: frame.sponsors, agent: frame.agent, lockMessage: frame.lockMessage, settings: frame.settings, tickCount: frame.tickCount, minTextRow });
|
|
2721
|
+
const playfield = this.buffer.toAnsiRows().map((row, i) => overlay.get(i) ?? row);
|
|
2722
|
+
const hud = renderHud({ columns: this.layout.columns, playerName: frame.playerName, score: frame.world.score, highScore: frame.mode === "pro" ? frame.scores.proHighScore : frame.scores.highScore, lives: frame.world.getLives(), mode: frame.mode, claude: frame.claude, tickCount: frame.tickCount });
|
|
2723
|
+
return [hud, ...playfield, renderFooter({ columns: this.layout.columns, phase: frame.phase })];
|
|
2724
|
+
}
|
|
2725
|
+
/** The Super Mario Bros. style plate lives in the level: it stays put on the title screen and scrolls off as you run. */
|
|
2726
|
+
drawTitleCard(x) {
|
|
2727
|
+
if (x === null) return;
|
|
2728
|
+
this.buffer.drawSprite({ sprite: TITLE_CARD, x, y: TITLE_CARD_TOP_ROW * PIXELS_PER_ROW3 });
|
|
2729
|
+
}
|
|
2730
|
+
/**
|
|
2731
|
+
* The single banner sponsor (top of the ranking): a plate the size of the title card with "sponsored by" above it.
|
|
2732
|
+
* Start screen: pinned next to the title (under it on narrower terminals) and scrolling away with it once the run
|
|
2733
|
+
* starts; pause and game over: centered on top.
|
|
2734
|
+
*/
|
|
2735
|
+
drawSponsorBanner(frame) {
|
|
2736
|
+
const sponsor = findDaySponsor({ sponsors: frame.sponsors, tier: "banner" });
|
|
2737
|
+
if (sponsor === null || !BANNER_PHASES.has(frame.phase)) return 0;
|
|
2738
|
+
const sprite = toSponsorSprite(sponsor);
|
|
2739
|
+
const titleX = TITLE_PINNED_PHASES.has(frame.phase) ? frame.world.getTitleX() : null;
|
|
2740
|
+
if (frame.phase === "running" && titleX === null) return 0;
|
|
2741
|
+
const placement = this.placeBanner({ sprite, titleX });
|
|
2742
|
+
if (placement === null) return 0;
|
|
2743
|
+
this.buffer.setText({ column: Math.round(placement.x), row: placement.y / PIXELS_PER_ROW3 - 1, text: `sponsored by ${sponsor.name.toUpperCase()}`, color: PALETTE.textDim, link: sponsor.url });
|
|
2744
|
+
this.buffer.drawSprite({ sprite, x: placement.x, y: placement.y, link: sponsor.url });
|
|
2745
|
+
return Math.ceil((placement.y + sprite.height) / PIXELS_PER_ROW3) + 1;
|
|
2746
|
+
}
|
|
2747
|
+
/** Null when the terminal is too small to show the banner and still leave room for the menu text under it. */
|
|
2748
|
+
placeBanner({ sprite, titleX }) {
|
|
2749
|
+
const cardTop = TITLE_CARD_TOP_ROW * PIXELS_PER_ROW3;
|
|
2750
|
+
const centered = Math.max(0, Math.floor((this.layout.columns - sprite.width) / 2));
|
|
2751
|
+
const fits = (placement) => (placement.y + sprite.height) / PIXELS_PER_ROW3 + MENU_TEXT_ROWS <= this.layout.playRows ? placement : null;
|
|
2752
|
+
if (titleX === null) return fits({ x: centered, y: cardTop });
|
|
2753
|
+
const besideX = titleX + TITLE_CARD.width + BANNER_GAP;
|
|
2754
|
+
if (besideX + sprite.width <= this.layout.columns) return fits({ x: besideX, y: cardTop });
|
|
2755
|
+
return fits({ x: titleX + Math.floor((TITLE_CARD.width - sprite.width) / 2), y: cardTop + TITLE_CARD.height + BANNER_LABEL_ROWS * PIXELS_PER_ROW3 });
|
|
2756
|
+
}
|
|
2757
|
+
/** Back to front: scenery, ground, blocks /** Back to front: scenery, ground, blocks, pickups, enemies, the mascot, popups. */
|
|
2758
|
+
drawWorld(world) {
|
|
2759
|
+
world.getScenery().forEach((item) => this.drawAtAltitude({ sprite: item.sprite, x: item.x, altitude: item.altitude }));
|
|
2760
|
+
this.drawGround(world.getDistance());
|
|
2761
|
+
world.getBlocks().forEach((block) => this.drawBlock(block));
|
|
2762
|
+
world.getTokens().forEach((token) => this.drawAtAltitude({ sprite: token.sprite, x: token.getX(), altitude: token.getAltitude(), link: token.link }));
|
|
2763
|
+
world.getPowerUps().forEach((powerUp) => this.drawPowerUp(powerUp));
|
|
2764
|
+
world.getObstacles().forEach((obstacle) => this.drawAtAltitude({ sprite: obstacle.sprite, x: obstacle.getX(), altitude: obstacle.getAltitude() }));
|
|
2765
|
+
if (this.isPlayerVisible(world)) this.drawAtAltitude({ sprite: world.player.getSprite(), x: world.player.getScreenX(), altitude: world.player.getAltitude() });
|
|
2766
|
+
world.getDebris().forEach((piece) => this.drawAtAltitude({ sprite: piece.sprite, x: piece.getX(), altitude: piece.getAltitude() }));
|
|
2767
|
+
world.getTexts().forEach((text) => this.drawText(text));
|
|
2768
|
+
}
|
|
2769
|
+
/** After losing a life the mascot blinks for the invulnerable stretch. */
|
|
2770
|
+
isPlayerVisible(world) {
|
|
2771
|
+
if (!world.isInvulnerable) return true;
|
|
2772
|
+
return Math.floor(world.getInvulnerableTicks() / GAME_CONSTANTS.hurtBlinkTicks) % 2 === 0;
|
|
2773
|
+
}
|
|
2774
|
+
drawBlock(block) {
|
|
2775
|
+
this.drawAtAltitude({ sprite: block.sprite, x: block.getX(), altitude: block.getAltitude() });
|
|
2776
|
+
}
|
|
2777
|
+
drawPowerUp(powerUp) {
|
|
2778
|
+
if (powerUp.textFace !== null) return this.drawTextFaceAt({ face: powerUp.textFace, x: powerUp.getX(), altitude: powerUp.getAltitude(), link: powerUp.link });
|
|
2779
|
+
this.drawAtAltitude({ sprite: powerUp.sprite, x: powerUp.getX(), altitude: powerUp.getAltitude(), link: powerUp.link });
|
|
2780
|
+
}
|
|
2781
|
+
drawTextFaceAt({ face, x, altitude, link }) {
|
|
2782
|
+
drawTextFace({ buffer: this.buffer, face, x, y: this.layout.groundY - altitude - GAME_CONSTANTS.blockSize, link });
|
|
2783
|
+
}
|
|
2784
|
+
/** Sponsor logos come as 16×12 high-density sprites: they are drawn with sextant glyphs into an 8×8 pixel area. */
|
|
2785
|
+
drawAtAltitude({ sprite, x, altitude, link = null }) {
|
|
2786
|
+
if (isHiResLogo(sprite)) {
|
|
2787
|
+
const y2 = this.layout.groundY - altitude - GAME_CONSTANTS.blockSize;
|
|
2788
|
+
return this.buffer.drawDenseSprite({ sprite, x, y: y2, link });
|
|
2789
|
+
}
|
|
2790
|
+
const y = this.layout.groundY - altitude - sprite.height;
|
|
2791
|
+
this.buffer.drawSprite({ sprite, x, y, link });
|
|
2792
|
+
}
|
|
2793
|
+
drawText(text) {
|
|
2794
|
+
const row = Math.floor((this.layout.groundY - text.getAltitude()) / PIXELS_PER_ROW3);
|
|
2795
|
+
this.buffer.setText({ column: Math.round(text.getX()), row, text: text.text, color: PALETTE.token });
|
|
2796
|
+
}
|
|
2797
|
+
/** A brick floor that scrolls with the world: a mortar line on top, vertical joints every brick. */
|
|
2798
|
+
drawGround(distance2) {
|
|
2799
|
+
const scroll = Math.floor(distance2);
|
|
2800
|
+
for (let x = 0; x < this.layout.columns; x++) {
|
|
2801
|
+
for (let dy = 0; dy < GAME_CONSTANTS.groundBrickRows; dy++) {
|
|
2802
|
+
const isMortar = dy === 0 || (x + scroll) % BRICK_WIDTH === 0;
|
|
2803
|
+
this.buffer.setPixel({ x, y: this.layout.groundY + dy, color: isMortar ? PALETTE.brickMortar : PALETTE.brick });
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
|
|
2809
|
+
// src/game/create_banner.ts
|
|
2810
|
+
function toTitleCase(label) {
|
|
2811
|
+
return label.charAt(0) + label.slice(1).toLowerCase();
|
|
2812
|
+
}
|
|
2813
|
+
var BANNER_BY_STATUS = {
|
|
2814
|
+
working: { build: (agent) => `${toTitleCase(agent)} is working... keep running!`, color: PALETTE.working },
|
|
2815
|
+
done: { build: (agent) => `${agent} IS DONE - go check your prompt!`, color: PALETTE.success },
|
|
2816
|
+
attention: { build: (agent) => `${agent} NEEDS YOUR INPUT - head back!`, color: PALETTE.warning }
|
|
2817
|
+
};
|
|
2818
|
+
function createBanner(record) {
|
|
2819
|
+
const template = BANNER_BY_STATUS[record.status];
|
|
2820
|
+
if (template === void 0) return null;
|
|
2821
|
+
return { text: template.build(AGENT_LABELS[record.agent] ?? AGENT_LABELS.claude), color: template.color, ticksLeft: GAME_CONSTANTS.bannerTicks };
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/game/is_agent_working.ts
|
|
2825
|
+
function isAgentWorking({ record, now = Date.now() }) {
|
|
2826
|
+
if (record === null || record.status !== "working") return false;
|
|
2827
|
+
return now - record.updatedAt <= GAME_CONSTANTS.claudeStaleMs;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// src/launcher/installed_terminals.ts
|
|
2831
|
+
function listInstalledTerminals() {
|
|
2832
|
+
const ides = IDE_KINDS.filter(isBundleInstalled);
|
|
2833
|
+
return [...ides, ...INSTALL_PREFERENCE.filter(isBundleInstalled), "apple_terminal"];
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
// src/game/settings_menu.ts
|
|
2837
|
+
var CHARACTERS = ["auto", ...AGENT_KINDS];
|
|
2838
|
+
var TERMINALS = ["auto", ...listInstalledTerminals()];
|
|
2839
|
+
var MODES2 = ["vibe", "pro"];
|
|
2840
|
+
function cycle(options, current, direction) {
|
|
2841
|
+
const index = Math.max(0, options.indexOf(current));
|
|
2842
|
+
return options[(index + direction + options.length) % options.length] ?? current;
|
|
2843
|
+
}
|
|
2844
|
+
function onOff(value) {
|
|
2845
|
+
return value ? "ON" : "OFF";
|
|
2846
|
+
}
|
|
2847
|
+
var ITEMS = [
|
|
2848
|
+
{ label: "open on every prompt", read: (config) => onOff(config.autoLaunch), change: (config) => ({ autoLaunch: !config.autoLaunch }) },
|
|
2849
|
+
{ label: "switch back when done", read: (config) => onOff(config.returnFocus), change: (config) => ({ returnFocus: !config.returnFocus }) },
|
|
2850
|
+
{ label: "character", read: (config) => config.character.toUpperCase(), change: (config, direction) => ({ character: cycle(CHARACTERS, config.character, direction) }) },
|
|
2851
|
+
{ label: "terminal", read: (config) => config.terminal, change: (config, direction) => ({ terminal: cycle(TERMINALS, config.terminal, direction) }) },
|
|
2852
|
+
{ label: "mode", read: (config) => GAME_MODE_LABELS[config.mode], change: (config, direction) => ({ mode: cycle(MODES2, config.mode, direction) }) }
|
|
2853
|
+
];
|
|
2854
|
+
var SettingsMenu = class {
|
|
2855
|
+
store;
|
|
2856
|
+
selected = 0;
|
|
2857
|
+
isOpenFlag = false;
|
|
2858
|
+
constructor({ store }) {
|
|
2859
|
+
this.store = store;
|
|
2860
|
+
}
|
|
2861
|
+
get isOpen() {
|
|
2862
|
+
return this.isOpenFlag;
|
|
2863
|
+
}
|
|
2864
|
+
toggle() {
|
|
2865
|
+
this.isOpenFlag = !this.isOpenFlag;
|
|
2866
|
+
}
|
|
2867
|
+
close() {
|
|
2868
|
+
this.isOpenFlag = false;
|
|
2869
|
+
}
|
|
2870
|
+
moveSelection(delta) {
|
|
2871
|
+
this.selected = (this.selected + delta + ITEMS.length) % ITEMS.length;
|
|
2872
|
+
}
|
|
2873
|
+
/** Applies the change for the highlighted row and returns the new config. */
|
|
2874
|
+
change(direction) {
|
|
2875
|
+
const item = ITEMS[this.selected];
|
|
2876
|
+
if (item === void 0) return this.store.load();
|
|
2877
|
+
return this.store.update(item.change(this.store.load(), direction));
|
|
2878
|
+
}
|
|
2879
|
+
getView() {
|
|
2880
|
+
const config = this.store.load();
|
|
2881
|
+
return { items: ITEMS.map((item) => ({ label: item.label, value: item.read(config) })), selected: this.selected };
|
|
2882
|
+
}
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
// src/render/sprites/mascot_sprites.ts
|
|
2886
|
+
var ALIVE = { "#": PALETTE.claudeOrange };
|
|
2887
|
+
var DEAD = { "#": PALETTE.mascotDead, x: PALETTE.danger };
|
|
2888
|
+
var BODY = [
|
|
2889
|
+
".########.",
|
|
2890
|
+
".########.",
|
|
2891
|
+
"##.####.##",
|
|
2892
|
+
"##########",
|
|
2893
|
+
".########.",
|
|
2894
|
+
".########."
|
|
2895
|
+
];
|
|
2896
|
+
var DUCK_BODY = [
|
|
2897
|
+
"..##########..",
|
|
2898
|
+
"###.######.###",
|
|
2899
|
+
".############."
|
|
2900
|
+
];
|
|
2901
|
+
var MASCOT_SPRITES = {
|
|
2902
|
+
runA: createSprite({ rows: [...BODY, ".#.#..#.#.", ".#.#..#.#."], palette: ALIVE }),
|
|
2903
|
+
runB: createSprite({ rows: [...BODY, "..##..##..", "..##..##.."], palette: ALIVE }),
|
|
2904
|
+
jump: createSprite({ rows: [...BODY, ".#.#..#.#.", ".........."], palette: ALIVE }),
|
|
2905
|
+
duckA: createSprite({ rows: [...DUCK_BODY, ".#..#....#..#.", ".#..#....#..#."], palette: ALIVE }),
|
|
2906
|
+
duckB: createSprite({ rows: [...DUCK_BODY, "..#..#..#..#..", "..#..#..#..#.."], palette: ALIVE }),
|
|
2907
|
+
dead: createSprite({
|
|
2908
|
+
rows: [".########.", ".########.", "##x####x##", "##########", ".########.", ".########.", ".#.#..#.#.", ".#.#..#.#."],
|
|
2909
|
+
palette: DEAD
|
|
2910
|
+
})
|
|
2911
|
+
};
|
|
2912
|
+
|
|
2913
|
+
// src/render/sprites/agent_sprites.ts
|
|
2914
|
+
var GROK = { w: PALETTE.cloud };
|
|
2915
|
+
var GROK_DEAD = { w: PALETTE.cloudShade, x: PALETTE.danger };
|
|
2916
|
+
var CRAB = { "#": PALETTE.bug, d: PALETTE.bugDark };
|
|
2917
|
+
var CRAB_DEAD = { "#": PALETTE.mascotDead, d: PALETTE.bugDark };
|
|
2918
|
+
var GROK_SPRITES = {
|
|
2919
|
+
runA: createSprite({ rows: ["...wwww...", "..wwwwww..", ".wwwwwwww.", ".ww.ww.ww.", ".ww.ww.ww.", ".wwwwwwww.", "..wwwwww..", "...wwww..."], palette: GROK }),
|
|
2920
|
+
runB: createSprite({ rows: ["...wwww...", "..wwwwww..", ".wwwwwwww.", ".wwwwwwww.", ".ww.ww.ww.", ".ww.ww.ww.", "..wwwwww..", "...wwww..."], palette: GROK }),
|
|
2921
|
+
jump: createSprite({ rows: ["...wwww...", "..wwwwww..", ".ww.ww.ww.", ".ww.ww.ww.", ".wwwwwwww.", ".wwwwwwww.", "..wwwwww..", "...wwww..."], palette: GROK }),
|
|
2922
|
+
duckA: createSprite({ rows: ["....wwwwww....", "..wwwwwwwwww..", ".www.wwww.www.", "..wwwwwwwwww..", "....wwwwww...."], palette: GROK }),
|
|
2923
|
+
duckB: createSprite({ rows: ["....wwwwww....", "..wwwwwwwwww..", ".wwwwwwwwwwww.", "..ww.wwww.ww..", "....wwwwww...."], palette: GROK }),
|
|
2924
|
+
dead: createSprite({ rows: ["...wwww...", "..wwwwww..", ".wwwwwwww.", ".wwxwwxww.", ".wwwwwwww.", ".wwwwwwww.", "..wwwwww..", "...wwww..."], palette: GROK_DEAD }),
|
|
2925
|
+
squished: createSprite({ rows: ["..wwwwww..", ".ww.ww.ww.", "wwwwwwwwww"], palette: GROK })
|
|
2926
|
+
};
|
|
2927
|
+
var CRAB_BODY = [".#......#.", "..#....#..", "...####...", "..#d##d#..", ".########.", "##########", ".########."];
|
|
2928
|
+
var CODEX_SPRITES = {
|
|
2929
|
+
runA: createSprite({ rows: [...CRAB_BODY, "#.#.#..#.#"], palette: CRAB }),
|
|
2930
|
+
runB: createSprite({ rows: [...CRAB_BODY, ".#.#.##.#."], palette: CRAB }),
|
|
2931
|
+
jump: createSprite({ rows: [...CRAB_BODY, ".#.#..#.#."], palette: CRAB }),
|
|
2932
|
+
duckA: createSprite({ rows: [".#..........#.", "..#..####..#..", "..##d####d##..", ".############.", "#.#.#....#.#.#"], palette: CRAB }),
|
|
2933
|
+
duckB: createSprite({ rows: [".#..........#.", "..#..####..#..", "..##d####d##..", ".############.", ".#.#.#..#.#.#."], palette: CRAB }),
|
|
2934
|
+
dead: createSprite({ rows: [...CRAB_BODY, "#.#.#..#.#"], palette: CRAB_DEAD }),
|
|
2935
|
+
squished: createSprite({ rows: ["..#d##d#..", ".########.", "##########"], palette: CRAB })
|
|
2936
|
+
};
|
|
2937
|
+
var CLAUDE_SPRITES = {
|
|
2938
|
+
...MASCOT_SPRITES,
|
|
2939
|
+
squished: createSprite({ rows: [".########.", "##.####.##", "##########"], palette: { "#": PALETTE.claudeOrange } })
|
|
2940
|
+
};
|
|
2941
|
+
var AGENT_SPRITES = {
|
|
2942
|
+
claude: CLAUDE_SPRITES,
|
|
2943
|
+
codex: CODEX_SPRITES,
|
|
2944
|
+
grok: GROK_SPRITES
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2947
|
+
// src/render/sprites/world_sprites.ts
|
|
2948
|
+
var QUESTION_ROWS = ["oooooooo", "oyddddyo", "oydyydyo", "oyyyydyo", "oyyddyyo", "oyyyyyyo", "oyyddyyo", "oooooooo"];
|
|
2949
|
+
var WORLD_SPRITES = {
|
|
2950
|
+
questionBlock: createSprite({
|
|
2951
|
+
rows: QUESTION_ROWS,
|
|
2952
|
+
palette: { o: PALETTE.blockShadow, y: PALETTE.blockYellow, d: PALETTE.brickMortar }
|
|
2953
|
+
}),
|
|
2954
|
+
questionBlockDim: createSprite({
|
|
2955
|
+
rows: QUESTION_ROWS,
|
|
2956
|
+
palette: { o: PALETTE.blockShadow, y: PALETTE.blockYellowDim, d: PALETTE.brickMortar }
|
|
2957
|
+
}),
|
|
2958
|
+
questionBlockBright: createSprite({
|
|
2959
|
+
rows: QUESTION_ROWS,
|
|
2960
|
+
palette: { o: PALETTE.blockYellow, y: PALETTE.blockYellowBright, d: PALETTE.brickMortar }
|
|
2961
|
+
}),
|
|
2962
|
+
brick: createSprite({
|
|
2963
|
+
rows: ["mmmmmmmm", "bbbmbbbb", "bbbmbbbb", "mmmmmmmm", "bbbbbbbm", "bbbbbbbm", "mmmmmmmm", "bbbmbbbb"],
|
|
2964
|
+
palette: { b: PALETTE.brick, m: PALETTE.brickMortar }
|
|
2965
|
+
}),
|
|
2966
|
+
brickChunk: createSprite({ rows: ["bb", "bm"], palette: { b: PALETTE.brick, m: PALETTE.brickMortar } }),
|
|
2967
|
+
usedBlock: createSprite({
|
|
2968
|
+
rows: ["mmmmmmmm", "mbbbbbbm", "mbbbbbbm", "mbbbbbbm", "mbbbbbbm", "mbbbbbbm", "mbbbbbbm", "mmmmmmmm"],
|
|
2969
|
+
palette: { m: PALETTE.blockUsedEdge, b: PALETTE.blockUsed }
|
|
2970
|
+
}),
|
|
2971
|
+
mushroom: createSprite({
|
|
2972
|
+
rows: ["..rrrr..", ".rwrrwr.", "rrrrrrrr", "rwrrrrwr", "rrrrrrrr", ".ssssss.", ".sessse.", "..ssss.."],
|
|
2973
|
+
palette: { r: PALETTE.mushroomRed, w: PALETTE.cloud, s: PALETTE.mushroomStem, e: PALETTE.brickMortar }
|
|
2974
|
+
}),
|
|
2975
|
+
/** The 1-UP: a red heart that pops out of some ? blocks and adds a life. */
|
|
2976
|
+
heart: createSprite({
|
|
2977
|
+
rows: [".rr..rr.", "rpprrrrr", "rprrrrrr", "rrrrrrrr", ".rrrrrr.", "..rrrr..", "...rr...", "........"],
|
|
2978
|
+
palette: { r: PALETTE.mushroomRed, p: PALETTE.heartLight }
|
|
2979
|
+
}),
|
|
2980
|
+
cloud: createSprite({
|
|
2981
|
+
rows: [".....wwwww......", "...wwwwwwwww....", ".wwwwwwwwwwwwww.", "wwwwwwwwwwwwwwww", ".llllllllllllll."],
|
|
2982
|
+
palette: { w: PALETTE.cloud, l: PALETTE.cloudShade }
|
|
2983
|
+
}),
|
|
2984
|
+
hill: createSprite({
|
|
2985
|
+
rows: ["..........gg..........", ".......gggggggg.......", ".....gggggggggggg.....", "...ggggggggdggggggg...", "..ggggggggggggggdggg..", ".gggggggggggggggggggg.", "gggggggggggggggggggggg"],
|
|
2986
|
+
palette: { g: PALETTE.hill, d: PALETTE.hillDark }
|
|
2987
|
+
})
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
// src/sponsors/to_text_face.ts
|
|
2991
|
+
var HEX_RADIX2 = 16;
|
|
2992
|
+
var MAX_LINES = 2;
|
|
2993
|
+
function hexToRgb2(hex) {
|
|
2994
|
+
return { r: Number.parseInt(hex.slice(1, 3), HEX_RADIX2), g: Number.parseInt(hex.slice(3, 5), HEX_RADIX2), b: Number.parseInt(hex.slice(5, 7), HEX_RADIX2) };
|
|
2995
|
+
}
|
|
2996
|
+
function splitLabel(label) {
|
|
2997
|
+
const width = GAME_CONSTANTS.blockSize;
|
|
2998
|
+
const trimmed = label.trim();
|
|
2999
|
+
if (trimmed.length <= width) return [trimmed];
|
|
3000
|
+
const space = trimmed.lastIndexOf(" ", width);
|
|
3001
|
+
const cut = space > 0 ? space : width;
|
|
3002
|
+
const rest = trimmed.slice(cut).trim().slice(0, width);
|
|
3003
|
+
return [trimmed.slice(0, cut).trim(), rest].slice(0, MAX_LINES);
|
|
3004
|
+
}
|
|
3005
|
+
function toTextFace(sponsor) {
|
|
3006
|
+
return { lines: splitLabel(sponsor.label), fg: hexToRgb2(sponsor.fg), bg: hexToRgb2(sponsor.bg) };
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// src/game/compute_speed.ts
|
|
3010
|
+
function computeSpeed({ distance: distance2 }) {
|
|
3011
|
+
const ramped = GAME_CONSTANTS.baseSpeed + distance2 * GAME_CONSTANTS.speedGainPerColumn;
|
|
3012
|
+
return Math.min(GAME_CONSTANTS.maxSpeed, ramped);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/game/debris.ts
|
|
3016
|
+
var Debris = class {
|
|
3017
|
+
sprite = WORLD_SPRITES.brickChunk;
|
|
3018
|
+
x;
|
|
3019
|
+
altitude;
|
|
3020
|
+
velocityX;
|
|
3021
|
+
velocityY;
|
|
3022
|
+
ticksLeft = GAME_CONSTANTS.debrisTicks;
|
|
3023
|
+
constructor({ x, altitude, velocityX, velocityY }) {
|
|
3024
|
+
this.x = x;
|
|
3025
|
+
this.altitude = altitude;
|
|
3026
|
+
this.velocityX = velocityX;
|
|
3027
|
+
this.velocityY = velocityY;
|
|
3028
|
+
}
|
|
3029
|
+
get isGone() {
|
|
3030
|
+
return this.ticksLeft <= 0 || this.altitude < -this.sprite.height;
|
|
3031
|
+
}
|
|
3032
|
+
update({ scroll }) {
|
|
3033
|
+
this.ticksLeft--;
|
|
3034
|
+
this.velocityY -= GAME_CONSTANTS.gravity;
|
|
3035
|
+
this.x += this.velocityX - scroll;
|
|
3036
|
+
this.altitude += this.velocityY;
|
|
3037
|
+
}
|
|
3038
|
+
getX() {
|
|
3039
|
+
return this.x;
|
|
3040
|
+
}
|
|
3041
|
+
getAltitude() {
|
|
3042
|
+
return this.altitude;
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
|
|
3046
|
+
// src/game/floating_text.ts
|
|
3047
|
+
var FloatingText = class {
|
|
3048
|
+
text;
|
|
3049
|
+
x;
|
|
3050
|
+
altitude;
|
|
3051
|
+
ticksLeft = GAME_CONSTANTS.floatingTextTicks;
|
|
3052
|
+
constructor({ text, x, altitude }) {
|
|
3053
|
+
this.text = text;
|
|
3054
|
+
this.x = x;
|
|
3055
|
+
this.altitude = altitude;
|
|
3056
|
+
}
|
|
3057
|
+
get isGone() {
|
|
3058
|
+
return this.ticksLeft <= 0;
|
|
3059
|
+
}
|
|
3060
|
+
update({ scroll }) {
|
|
3061
|
+
this.ticksLeft--;
|
|
3062
|
+
this.x -= scroll;
|
|
3063
|
+
this.altitude += GAME_CONSTANTS.floatingTextRise;
|
|
3064
|
+
}
|
|
3065
|
+
getX() {
|
|
3066
|
+
return this.x;
|
|
3067
|
+
}
|
|
3068
|
+
getAltitude() {
|
|
3069
|
+
return this.altitude;
|
|
3070
|
+
}
|
|
3071
|
+
};
|
|
3072
|
+
|
|
3073
|
+
// src/game/has_collision.ts
|
|
3074
|
+
function hasCollision({ a, b }) {
|
|
3075
|
+
const overlapsX2 = a.x < b.x + b.width && a.x + a.width > b.x;
|
|
3076
|
+
const overlapsY = a.y < b.y + b.height && a.y + a.height > b.y;
|
|
3077
|
+
return overlapsX2 && overlapsY;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// src/render/sprites/crab_sprites.ts
|
|
3081
|
+
var CRAB_BODY2 = [".#...#.", "..###..", ".#d#d#.", "#######", ".#####."];
|
|
3082
|
+
var CRAB_PALETTE = { "#": PALETTE.bug, d: PALETTE.bugDark };
|
|
3083
|
+
var RED_CRAB = {
|
|
3084
|
+
runA: createSprite({ rows: [...CRAB_BODY2, "#.#.#.#"], palette: CRAB_PALETTE }),
|
|
3085
|
+
runB: createSprite({ rows: [...CRAB_BODY2, ".#.#.#."], palette: CRAB_PALETTE }),
|
|
3086
|
+
squished: createSprite({ rows: [".#d#d#.", "#######"], palette: CRAB_PALETTE })
|
|
3087
|
+
};
|
|
3088
|
+
var CRAB_SPRITES = {
|
|
3089
|
+
claude: AGENT_SPRITES.claude,
|
|
3090
|
+
codex: RED_CRAB,
|
|
3091
|
+
grok: AGENT_SPRITES.grok
|
|
3092
|
+
};
|
|
3093
|
+
|
|
3094
|
+
// src/render/sprites/obstacle_sprites.ts
|
|
3095
|
+
var PIPE_PALETTE = { d: PALETTE.pipeDark, g: PALETTE.pipeGreen, l: PALETTE.pipeLight };
|
|
3096
|
+
var PIPE_BODY_ROW = ".dlgggggggd.";
|
|
3097
|
+
var OBSTACLE_SPRITES = {
|
|
3098
|
+
pipe: createSprite({
|
|
3099
|
+
rows: ["dddddddddddd", "dlggggggggld", "dddddddddddd", ...Array.from({ length: 9 }, () => PIPE_BODY_ROW)],
|
|
3100
|
+
palette: PIPE_PALETTE
|
|
3101
|
+
}),
|
|
3102
|
+
ghost: createSprite({
|
|
3103
|
+
rows: ["..####..", ".######.", "##e##e##", "########", "########", "#.#..#.#"],
|
|
3104
|
+
palette: { "#": PALETTE.ghost, e: PALETTE.ghostEye }
|
|
3105
|
+
})
|
|
3106
|
+
};
|
|
3107
|
+
|
|
3108
|
+
// src/game/obstacle.ts
|
|
3109
|
+
var BOB_PERIOD_TICKS = 6;
|
|
3110
|
+
var BOB_AMPLITUDE = 1;
|
|
3111
|
+
var WALK_FRAME_TICKS = 6;
|
|
3112
|
+
var Obstacle = class {
|
|
3113
|
+
kind;
|
|
3114
|
+
enemy;
|
|
3115
|
+
baseAltitude;
|
|
3116
|
+
ownSpeed;
|
|
3117
|
+
x;
|
|
3118
|
+
walkDirection = -1;
|
|
3119
|
+
age = 0;
|
|
3120
|
+
squishedTicksLeft = -1;
|
|
3121
|
+
constructor({ kind, x, altitude = 0, ownSpeed = 0, enemy = CRAB_SPRITES.codex }) {
|
|
3122
|
+
this.kind = kind;
|
|
3123
|
+
this.enemy = enemy;
|
|
3124
|
+
this.x = x;
|
|
3125
|
+
this.baseAltitude = altitude;
|
|
3126
|
+
this.ownSpeed = ownSpeed;
|
|
3127
|
+
}
|
|
3128
|
+
get sprite() {
|
|
3129
|
+
if (this.kind === "pipe") return OBSTACLE_SPRITES.pipe;
|
|
3130
|
+
if (this.kind === "ghost") return OBSTACLE_SPRITES.ghost;
|
|
3131
|
+
if (this.isSquished) return this.enemy.squished;
|
|
3132
|
+
return Math.floor(this.age / WALK_FRAME_TICKS) % 2 === 0 ? this.enemy.runA : this.enemy.runB;
|
|
3133
|
+
}
|
|
3134
|
+
get isStompable() {
|
|
3135
|
+
return this.kind === "bug" && !this.isSquished;
|
|
3136
|
+
}
|
|
3137
|
+
get isSquished() {
|
|
3138
|
+
return this.squishedTicksLeft >= 0;
|
|
3139
|
+
}
|
|
3140
|
+
get isGone() {
|
|
3141
|
+
return this.squishedTicksLeft === 0;
|
|
3142
|
+
}
|
|
3143
|
+
get isOffscreen() {
|
|
3144
|
+
return this.x + this.sprite.width < 0;
|
|
3145
|
+
}
|
|
3146
|
+
/** Enemies walk on their own (left at first); the scroll is the player's running. */
|
|
3147
|
+
update({ scroll }) {
|
|
3148
|
+
this.age++;
|
|
3149
|
+
if (this.squishedTicksLeft > 0) this.squishedTicksLeft--;
|
|
3150
|
+
this.x += (this.isSquished ? 0 : this.ownSpeed * this.walkDirection) - scroll;
|
|
3151
|
+
}
|
|
3152
|
+
/** Pipes are walls for walkers too: step out of the pipe and turn around. */
|
|
3153
|
+
bounceOff(barrier) {
|
|
3154
|
+
const isLeftOfBarrier = this.x + this.sprite.width / 2 < barrier.x + barrier.width / 2;
|
|
3155
|
+
this.x = isLeftOfBarrier ? barrier.x - this.sprite.width : barrier.x + barrier.width;
|
|
3156
|
+
this.walkDirection = isLeftOfBarrier ? -1 : 1;
|
|
3157
|
+
}
|
|
3158
|
+
squish() {
|
|
3159
|
+
this.squishedTicksLeft = GAME_CONSTANTS.squishTicks;
|
|
3160
|
+
}
|
|
3161
|
+
getBounds() {
|
|
3162
|
+
return { x: this.x, y: this.getAltitude(), width: this.sprite.width, height: this.sprite.height };
|
|
3163
|
+
}
|
|
3164
|
+
getX() {
|
|
3165
|
+
return this.x;
|
|
3166
|
+
}
|
|
3167
|
+
/** Flying obstacles bob up and down; grounded ones stay put. */
|
|
3168
|
+
getAltitude() {
|
|
3169
|
+
if (this.baseAltitude === 0) return 0;
|
|
3170
|
+
return this.baseAltitude + Math.round(Math.sin(this.age / BOB_PERIOD_TICKS) * BOB_AMPLITUDE);
|
|
3171
|
+
}
|
|
3172
|
+
};
|
|
3173
|
+
|
|
3174
|
+
// src/game/player.ts
|
|
3175
|
+
var FRAME_COUNT = 2;
|
|
3176
|
+
var RESTING_EPSILON = 0.01;
|
|
3177
|
+
var NO_PRESS = -1e3;
|
|
3178
|
+
var Player = class {
|
|
3179
|
+
sprites = AGENT_SPRITES.claude;
|
|
3180
|
+
altitude = 0;
|
|
3181
|
+
velocity = 0;
|
|
3182
|
+
floor = 0;
|
|
3183
|
+
screenX = GAME_CONSTANTS.playerX;
|
|
3184
|
+
direction = 0;
|
|
3185
|
+
moveTicksLeft = 0;
|
|
3186
|
+
isHeldRight = false;
|
|
3187
|
+
isHeldLeft = false;
|
|
3188
|
+
hasHoldMode = false;
|
|
3189
|
+
hasReliableRelease = false;
|
|
3190
|
+
hasAirMomentum = false;
|
|
3191
|
+
isStickyAfterJump = false;
|
|
3192
|
+
lastPressTick = NO_PRESS;
|
|
3193
|
+
duckTicksLeft = 0;
|
|
3194
|
+
animationTick = 0;
|
|
3195
|
+
isDead = false;
|
|
3196
|
+
reset() {
|
|
3197
|
+
this.altitude = 0;
|
|
3198
|
+
this.velocity = 0;
|
|
3199
|
+
this.floor = 0;
|
|
3200
|
+
this.screenX = GAME_CONSTANTS.playerX;
|
|
3201
|
+
this.direction = 0;
|
|
3202
|
+
this.moveTicksLeft = 0;
|
|
3203
|
+
this.isHeldRight = false;
|
|
3204
|
+
this.isHeldLeft = false;
|
|
3205
|
+
this.hasAirMomentum = false;
|
|
3206
|
+
this.isStickyAfterJump = false;
|
|
3207
|
+
this.lastPressTick = NO_PRESS;
|
|
3208
|
+
this.duckTicksLeft = 0;
|
|
3209
|
+
this.animationTick = 0;
|
|
3210
|
+
this.isDead = false;
|
|
3211
|
+
}
|
|
3212
|
+
setSprites(sprites) {
|
|
3213
|
+
this.sprites = sprites;
|
|
3214
|
+
}
|
|
3215
|
+
get isOnGround() {
|
|
3216
|
+
return this.velocity === 0 && this.altitude <= this.floor + RESTING_EPSILON;
|
|
3217
|
+
}
|
|
3218
|
+
get isDucking() {
|
|
3219
|
+
return this.duckTicksLeft > 0 && this.isOnGround;
|
|
3220
|
+
}
|
|
3221
|
+
get isFalling() {
|
|
3222
|
+
return this.velocity < 0;
|
|
3223
|
+
}
|
|
3224
|
+
/** With key-release reports (Kitty protocol) a held key moves; otherwise key repeats keep a short timer alive. */
|
|
3225
|
+
get isMoving() {
|
|
3226
|
+
if (this.isDead) return false;
|
|
3227
|
+
if (this.hasHoldMode) return this.isHeldNow && !this.isHeartbeatLost;
|
|
3228
|
+
return this.moveTicksLeft > 0 || this.hasAirMomentum && !this.isOnGround || this.isStickyAfterJump;
|
|
3229
|
+
}
|
|
3230
|
+
get isHeldNow() {
|
|
3231
|
+
return this.direction > 0 ? this.isHeldRight : this.direction < 0 && this.isHeldLeft;
|
|
3232
|
+
}
|
|
3233
|
+
/** Safety net: a "held" key that has sent nothing for seconds was released without a report. */
|
|
3234
|
+
get isHeartbeatLost() {
|
|
3235
|
+
if (this.hasReliableRelease) return false;
|
|
3236
|
+
return this.animationTick - this.lastPressTick > GAME_CONSTANTS.holdHeartbeatTicks;
|
|
3237
|
+
}
|
|
3238
|
+
/** Releases will be reported; `reliable` (the OS key-state watcher) switches the heartbeat safety net off. */
|
|
3239
|
+
enableHoldMode({ reliable = false } = {}) {
|
|
3240
|
+
this.hasHoldMode = true;
|
|
3241
|
+
this.hasReliableRelease = this.hasReliableRelease || reliable;
|
|
3242
|
+
}
|
|
3243
|
+
jump() {
|
|
3244
|
+
if (!this.isOnGround || this.isDead) return;
|
|
3245
|
+
this.duckTicksLeft = 0;
|
|
3246
|
+
this.hasAirMomentum = this.isMoving;
|
|
3247
|
+
this.velocity = GAME_CONSTANTS.jumpVelocity;
|
|
3248
|
+
}
|
|
3249
|
+
duck() {
|
|
3250
|
+
if (this.isDead) return;
|
|
3251
|
+
this.duckTicksLeft = GAME_CONSTANTS.duckTicks;
|
|
3252
|
+
if (!this.isOnGround) this.velocity = Math.min(this.velocity, GAME_CONSTANTS.fastFallVelocity);
|
|
3253
|
+
}
|
|
3254
|
+
moveRight() {
|
|
3255
|
+
this.isHeldRight = true;
|
|
3256
|
+
this.press(1);
|
|
3257
|
+
}
|
|
3258
|
+
moveLeft() {
|
|
3259
|
+
this.isHeldLeft = true;
|
|
3260
|
+
this.press(-1);
|
|
3261
|
+
}
|
|
3262
|
+
releaseRight() {
|
|
3263
|
+
this.isHeldRight = false;
|
|
3264
|
+
if (this.isHeldLeft) this.direction = -1;
|
|
3265
|
+
}
|
|
3266
|
+
releaseLeft() {
|
|
3267
|
+
this.isHeldLeft = false;
|
|
3268
|
+
if (this.isHeldRight) this.direction = 1;
|
|
3269
|
+
}
|
|
3270
|
+
/** Stomp rebound: a small hop off the enemy's back. */
|
|
3271
|
+
bounce() {
|
|
3272
|
+
this.velocity = GAME_CONSTANTS.bounceVelocity;
|
|
3273
|
+
}
|
|
3274
|
+
/** Head hit a block: stop rising immediately and fall back down. */
|
|
3275
|
+
bonk() {
|
|
3276
|
+
this.velocity = Math.min(this.velocity, 0);
|
|
3277
|
+
}
|
|
3278
|
+
/** The altitude the player can stand on right now (ground or a block top under them). */
|
|
3279
|
+
setFloor(altitude) {
|
|
3280
|
+
this.floor = altitude;
|
|
3281
|
+
}
|
|
3282
|
+
markDead() {
|
|
3283
|
+
this.isDead = true;
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Horizontal step for this tick. Walking left or catching up to the home column moves the mascot on
|
|
3287
|
+
* screen; running right past the home column scrolls the world instead, and that scroll is returned.
|
|
3288
|
+
*/
|
|
3289
|
+
advanceHorizontal({ runSpeed }) {
|
|
3290
|
+
if (!this.isMoving) return 0;
|
|
3291
|
+
this.moveTicksLeft = Math.max(0, this.moveTicksLeft - 1);
|
|
3292
|
+
if (this.direction < 0) {
|
|
3293
|
+
this.screenX = Math.max(GAME_CONSTANTS.playerMinX, this.screenX - GAME_CONSTANTS.walkSpeed);
|
|
3294
|
+
return 0;
|
|
3295
|
+
}
|
|
3296
|
+
if (this.screenX < GAME_CONSTANTS.playerX) {
|
|
3297
|
+
this.screenX = Math.min(GAME_CONSTANTS.playerX, this.screenX + GAME_CONSTANTS.walkSpeed);
|
|
3298
|
+
return 0;
|
|
3299
|
+
}
|
|
3300
|
+
return runSpeed;
|
|
3301
|
+
}
|
|
3302
|
+
update() {
|
|
3303
|
+
this.animationTick++;
|
|
3304
|
+
if (this.duckTicksLeft > 0) this.duckTicksLeft--;
|
|
3305
|
+
if (this.isOnGround) return;
|
|
3306
|
+
this.velocity -= GAME_CONSTANTS.gravity;
|
|
3307
|
+
this.altitude += this.velocity;
|
|
3308
|
+
if (this.altitude > this.floor || this.velocity > 0) return;
|
|
3309
|
+
this.altitude = this.floor;
|
|
3310
|
+
this.velocity = 0;
|
|
3311
|
+
this.land();
|
|
3312
|
+
}
|
|
3313
|
+
/** Without release reports the OS never resumes the arrow's repeats after a jump, so the run stays on until the next press. */
|
|
3314
|
+
land() {
|
|
3315
|
+
if (!this.hasAirMomentum) return;
|
|
3316
|
+
this.hasAirMomentum = false;
|
|
3317
|
+
if (!this.hasHoldMode) this.isStickyAfterJump = this.direction > 0;
|
|
3318
|
+
}
|
|
3319
|
+
getSprite() {
|
|
3320
|
+
if (this.isDead) return this.sprites.dead;
|
|
3321
|
+
if (!this.isOnGround) return this.sprites.jump;
|
|
3322
|
+
const frame = this.isMoving ? Math.floor(this.animationTick / GAME_CONSTANTS.runFrameTicks) % FRAME_COUNT : 0;
|
|
3323
|
+
if (this.isDucking) return frame === 0 ? this.sprites.duckA : this.sprites.duckB;
|
|
3324
|
+
return frame === 0 ? this.sprites.runA : this.sprites.runB;
|
|
3325
|
+
}
|
|
3326
|
+
/** World-space hit box, slightly inset on the sides and top so near misses feel fair. */
|
|
3327
|
+
getBounds() {
|
|
3328
|
+
const sprite = this.getSprite();
|
|
3329
|
+
return {
|
|
3330
|
+
x: this.screenX + GAME_CONSTANTS.collisionSideInset,
|
|
3331
|
+
y: this.altitude,
|
|
3332
|
+
width: sprite.width - GAME_CONSTANTS.collisionSideInset * 2,
|
|
3333
|
+
height: sprite.height - GAME_CONSTANTS.collisionTopInset
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
getScreenX() {
|
|
3337
|
+
return this.screenX;
|
|
3338
|
+
}
|
|
3339
|
+
getAltitude() {
|
|
3340
|
+
return this.altitude;
|
|
3341
|
+
}
|
|
3342
|
+
getVelocity() {
|
|
3343
|
+
return this.velocity;
|
|
3344
|
+
}
|
|
3345
|
+
/** Terminals have no key-up: a fresh press holds longer, key-repeat presses only top the timer up. */
|
|
3346
|
+
press(direction) {
|
|
3347
|
+
if (this.isDead) return;
|
|
3348
|
+
this.isStickyAfterJump = false;
|
|
3349
|
+
const isRepeat = this.animationTick - this.lastPressTick < GAME_CONSTANTS.moveRepeatWindowTicks;
|
|
3350
|
+
this.moveTicksLeft = isRepeat ? GAME_CONSTANTS.moveRepeatTicks : GAME_CONSTANTS.moveHoldTicks;
|
|
3351
|
+
this.lastPressTick = this.animationTick;
|
|
3352
|
+
this.direction = direction;
|
|
3353
|
+
}
|
|
3354
|
+
};
|
|
3355
|
+
|
|
3356
|
+
// src/game/power_up.ts
|
|
3357
|
+
var PowerUp = class {
|
|
3358
|
+
sprite;
|
|
3359
|
+
link;
|
|
3360
|
+
label;
|
|
3361
|
+
textFace;
|
|
3362
|
+
kind;
|
|
3363
|
+
emergeTarget;
|
|
3364
|
+
x;
|
|
3365
|
+
altitude;
|
|
3366
|
+
velocity = 0;
|
|
3367
|
+
walkDirection = 1;
|
|
3368
|
+
phase = "emerging";
|
|
3369
|
+
constructor({ sprite, link, label, x, altitude, textFace = null, kind = "bonus" }) {
|
|
3370
|
+
this.sprite = sprite;
|
|
3371
|
+
this.kind = kind;
|
|
3372
|
+
this.textFace = textFace;
|
|
3373
|
+
this.link = link;
|
|
3374
|
+
this.label = label;
|
|
3375
|
+
this.x = x;
|
|
3376
|
+
this.altitude = altitude;
|
|
3377
|
+
this.emergeTarget = altitude + this.getHeight();
|
|
3378
|
+
}
|
|
3379
|
+
/** Still rising out of the block: not collectible yet, so a bonk does not instantly eat it. */
|
|
3380
|
+
get isEmerging() {
|
|
3381
|
+
return this.phase === "emerging";
|
|
3382
|
+
}
|
|
3383
|
+
get isOffscreen() {
|
|
3384
|
+
return this.x + this.getWidth() < 0;
|
|
3385
|
+
}
|
|
3386
|
+
/** On-screen footprint: a high-density logo occupies one block cell (8×8 pixels). */
|
|
3387
|
+
getWidth() {
|
|
3388
|
+
return isHiResLogo(this.sprite) || this.textFace !== null ? GAME_CONSTANTS.blockSize : this.sprite.width;
|
|
3389
|
+
}
|
|
3390
|
+
getHeight() {
|
|
3391
|
+
return isHiResLogo(this.sprite) || this.textFace !== null ? GAME_CONSTANTS.blockSize : this.sprite.height;
|
|
3392
|
+
}
|
|
3393
|
+
/** After emerging it walks right on its own, so it drifts toward the player even while they stand still. */
|
|
3394
|
+
update({ scroll }) {
|
|
3395
|
+
if (this.phase === "emerging") return this.emerge({ scroll });
|
|
3396
|
+
this.x += GAME_CONSTANTS.powerUpWalkSpeed * this.walkDirection - scroll;
|
|
3397
|
+
this.velocity -= GAME_CONSTANTS.gravity;
|
|
3398
|
+
this.altitude = Math.max(0, this.altitude + this.velocity);
|
|
3399
|
+
if (this.altitude === 0) this.velocity = 0;
|
|
3400
|
+
}
|
|
3401
|
+
/** Like a Mario mushroom it turns around at pipes. */
|
|
3402
|
+
bounceOff(barrier) {
|
|
3403
|
+
const isLeftOfBarrier = this.x + this.getWidth() / 2 < barrier.x + barrier.width / 2;
|
|
3404
|
+
this.x = isLeftOfBarrier ? barrier.x - this.getWidth() : barrier.x + barrier.width;
|
|
3405
|
+
this.walkDirection = isLeftOfBarrier ? -1 : 1;
|
|
3406
|
+
}
|
|
3407
|
+
getBounds() {
|
|
3408
|
+
return { x: this.x, y: this.altitude, width: this.getWidth(), height: this.getHeight() };
|
|
3409
|
+
}
|
|
3410
|
+
getX() {
|
|
3411
|
+
return this.x;
|
|
3412
|
+
}
|
|
3413
|
+
getAltitude() {
|
|
3414
|
+
return this.altitude;
|
|
3415
|
+
}
|
|
3416
|
+
emerge({ scroll }) {
|
|
3417
|
+
this.x -= scroll;
|
|
3418
|
+
this.altitude = Math.min(this.emergeTarget, this.altitude + GAME_CONSTANTS.powerUpEmergeSpeed);
|
|
3419
|
+
if (this.altitude >= this.emergeTarget) this.phase = "moving";
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
|
|
3423
|
+
// src/game/question_block.ts
|
|
3424
|
+
var BLINK_FRAME_TICKS = 7;
|
|
3425
|
+
var BLINK_CYCLE = [
|
|
3426
|
+
WORLD_SPRITES.questionBlock,
|
|
3427
|
+
WORLD_SPRITES.questionBlock,
|
|
3428
|
+
WORLD_SPRITES.questionBlock,
|
|
3429
|
+
WORLD_SPRITES.questionBlockDim,
|
|
3430
|
+
WORLD_SPRITES.questionBlockBright,
|
|
3431
|
+
WORLD_SPRITES.questionBlock
|
|
3432
|
+
];
|
|
3433
|
+
var QuestionBlock = class {
|
|
3434
|
+
kind;
|
|
3435
|
+
sponsor;
|
|
3436
|
+
x;
|
|
3437
|
+
age = 0;
|
|
3438
|
+
isUsed = false;
|
|
3439
|
+
constructor({ x, sponsor, kind = "question" }) {
|
|
3440
|
+
this.x = x;
|
|
3441
|
+
this.sponsor = sponsor;
|
|
3442
|
+
this.kind = kind;
|
|
3443
|
+
}
|
|
3444
|
+
get sprite() {
|
|
3445
|
+
if (this.kind === "brick") return WORLD_SPRITES.brick;
|
|
3446
|
+
if (this.isUsed) return WORLD_SPRITES.usedBlock;
|
|
3447
|
+
return BLINK_CYCLE[Math.floor(this.age / BLINK_FRAME_TICKS) % BLINK_CYCLE.length] ?? WORLD_SPRITES.questionBlock;
|
|
3448
|
+
}
|
|
3449
|
+
get isFresh() {
|
|
3450
|
+
return !this.isUsed;
|
|
3451
|
+
}
|
|
3452
|
+
/** A smashed brick leaves the world entirely. */
|
|
3453
|
+
get isGone() {
|
|
3454
|
+
return this.kind === "brick" && this.isUsed;
|
|
3455
|
+
}
|
|
3456
|
+
get isOffscreen() {
|
|
3457
|
+
return this.x + GAME_CONSTANTS.blockSize < 0;
|
|
3458
|
+
}
|
|
3459
|
+
update({ scroll }) {
|
|
3460
|
+
this.x -= scroll;
|
|
3461
|
+
this.age++;
|
|
3462
|
+
}
|
|
3463
|
+
hit() {
|
|
3464
|
+
this.isUsed = true;
|
|
3465
|
+
}
|
|
3466
|
+
getX() {
|
|
3467
|
+
return this.x;
|
|
3468
|
+
}
|
|
3469
|
+
getAltitude() {
|
|
3470
|
+
return GAME_CONSTANTS.blockAltitude;
|
|
3471
|
+
}
|
|
3472
|
+
getTop() {
|
|
3473
|
+
return GAME_CONSTANTS.blockAltitude + GAME_CONSTANTS.blockSize;
|
|
3474
|
+
}
|
|
3475
|
+
getBounds() {
|
|
3476
|
+
return { x: this.x, y: this.getAltitude(), width: GAME_CONSTANTS.blockSize, height: GAME_CONSTANTS.blockSize };
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
|
|
3480
|
+
// src/game/scenery.ts
|
|
3481
|
+
var Scenery = class {
|
|
3482
|
+
random;
|
|
3483
|
+
items = [];
|
|
3484
|
+
untilCloud = 0;
|
|
3485
|
+
untilHill = GAME_CONSTANTS.hillInterval / 2;
|
|
3486
|
+
constructor({ random = Math.random } = {}) {
|
|
3487
|
+
this.random = random;
|
|
3488
|
+
}
|
|
3489
|
+
reset() {
|
|
3490
|
+
this.items = [];
|
|
3491
|
+
this.untilCloud = 0;
|
|
3492
|
+
this.untilHill = GAME_CONSTANTS.hillInterval / 2;
|
|
3493
|
+
}
|
|
3494
|
+
getItems() {
|
|
3495
|
+
return this.items;
|
|
3496
|
+
}
|
|
3497
|
+
update({ scroll, screenWidth }) {
|
|
3498
|
+
this.items.forEach((item) => {
|
|
3499
|
+
item.x -= scroll * item.parallax;
|
|
3500
|
+
});
|
|
3501
|
+
this.items = this.items.filter((item) => item.x + item.sprite.width >= 0);
|
|
3502
|
+
this.untilCloud -= scroll;
|
|
3503
|
+
this.untilHill -= scroll;
|
|
3504
|
+
if (this.untilCloud <= 0) this.spawnCloud(screenWidth);
|
|
3505
|
+
if (this.untilHill <= 0) this.spawnHill(screenWidth);
|
|
3506
|
+
}
|
|
3507
|
+
spawnCloud(screenWidth) {
|
|
3508
|
+
const altitude = GAME_CONSTANTS.cloudMinAltitude + Math.floor(this.random() * GAME_CONSTANTS.cloudAltitudeSpread);
|
|
3509
|
+
this.items.push({ sprite: WORLD_SPRITES.cloud, parallax: GAME_CONSTANTS.cloudParallax, x: screenWidth, altitude });
|
|
3510
|
+
this.untilCloud = GAME_CONSTANTS.cloudInterval * (0.6 + this.random() * 0.8);
|
|
3511
|
+
}
|
|
3512
|
+
spawnHill(screenWidth) {
|
|
3513
|
+
this.items.push({ sprite: WORLD_SPRITES.hill, parallax: GAME_CONSTANTS.hillParallax, x: screenWidth, altitude: 0 });
|
|
3514
|
+
this.untilHill = GAME_CONSTANTS.hillInterval * (0.7 + this.random() * 0.6);
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
|
|
3518
|
+
// src/render/sprites/token_sprite.ts
|
|
3519
|
+
var TOKEN_SPRITE = createSprite({
|
|
3520
|
+
rows: [".gggg.", "gsggdg", "gsggdg", "gsggdg", "gsggdg", ".gggg."],
|
|
3521
|
+
palette: { g: PALETTE.token, s: PALETTE.tokenShine, d: PALETTE.tokenDark }
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
// src/sponsors/pick_ranked_sponsor.ts
|
|
3525
|
+
function pickRankedSponsor({ sponsors, roll }) {
|
|
3526
|
+
const count = sponsors.length;
|
|
3527
|
+
if (count === 0) return null;
|
|
3528
|
+
const totalShares = count * (count + 1) / 2;
|
|
3529
|
+
let remaining = roll * totalShares;
|
|
3530
|
+
for (let i = 0; i < count; i++) {
|
|
3531
|
+
const shares = count - i;
|
|
3532
|
+
if (remaining < shares) return sponsors[i] ?? null;
|
|
3533
|
+
remaining -= shares;
|
|
3534
|
+
}
|
|
3535
|
+
return sponsors[0] ?? null;
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// src/game/token.ts
|
|
3539
|
+
var BOB_PERIOD_TICKS2 = 8;
|
|
3540
|
+
var BOB_AMPLITUDE2 = 1.5;
|
|
3541
|
+
var Token = class {
|
|
3542
|
+
sprite;
|
|
3543
|
+
link;
|
|
3544
|
+
baseAltitude;
|
|
3545
|
+
x;
|
|
3546
|
+
age = 0;
|
|
3547
|
+
isCollectedFlag = false;
|
|
3548
|
+
constructor({ x, altitude, sprite = TOKEN_SPRITE, link = null }) {
|
|
3549
|
+
this.x = x;
|
|
3550
|
+
this.baseAltitude = altitude;
|
|
3551
|
+
this.sprite = sprite;
|
|
3552
|
+
this.link = link;
|
|
3553
|
+
}
|
|
3554
|
+
get isCollected() {
|
|
3555
|
+
return this.isCollectedFlag;
|
|
3556
|
+
}
|
|
3557
|
+
get isOffscreen() {
|
|
3558
|
+
return this.x + this.sprite.width < 0;
|
|
3559
|
+
}
|
|
3560
|
+
update({ scroll }) {
|
|
3561
|
+
this.x -= scroll;
|
|
3562
|
+
this.age++;
|
|
3563
|
+
}
|
|
3564
|
+
collect() {
|
|
3565
|
+
this.isCollectedFlag = true;
|
|
3566
|
+
}
|
|
3567
|
+
getBounds() {
|
|
3568
|
+
return { x: this.x, y: this.getAltitude(), width: this.sprite.width, height: this.sprite.height };
|
|
3569
|
+
}
|
|
3570
|
+
getX() {
|
|
3571
|
+
return this.x;
|
|
3572
|
+
}
|
|
3573
|
+
getAltitude() {
|
|
3574
|
+
return this.baseAltitude + Math.round(Math.sin(this.age / BOB_PERIOD_TICKS2) * BOB_AMPLITUDE2);
|
|
3575
|
+
}
|
|
3576
|
+
};
|
|
3577
|
+
|
|
3578
|
+
// src/game/spawner.ts
|
|
3579
|
+
var SPAWN_MARGIN = 2;
|
|
3580
|
+
var BUG_PAIR_OFFSET = 9;
|
|
3581
|
+
var GAP_FRACTION = 0.45;
|
|
3582
|
+
var HIGH_GHOST_CHANCE = 0.5;
|
|
3583
|
+
var PREVIEW_ENEMY_OFFSET = 30;
|
|
3584
|
+
var PREVIEW_ENEMY_SPACING = 14;
|
|
3585
|
+
var PREVIEW_COIN_OFFSET = 26;
|
|
3586
|
+
var PREVIEW_COIN_ALTITUDE = 10;
|
|
3587
|
+
var PREVIEW_BLOCK_OFFSET = 0;
|
|
3588
|
+
var PREVIEW_CARD_CLEARANCE = 2;
|
|
3589
|
+
var EMPTY_RESULT = { obstacles: [], tokens: [], blocks: [] };
|
|
3590
|
+
var Spawner = class {
|
|
3591
|
+
random;
|
|
3592
|
+
distanceUntilNext = GAME_CONSTANTS.minGapColumns;
|
|
3593
|
+
mysterySponsors = [];
|
|
3594
|
+
isFirstBlockOfRun = true;
|
|
3595
|
+
rivals = ["codex", "grok"];
|
|
3596
|
+
rivalIndex = 0;
|
|
3597
|
+
constructor({ random = Math.random } = {}) {
|
|
3598
|
+
this.random = random;
|
|
3599
|
+
}
|
|
3600
|
+
reset() {
|
|
3601
|
+
this.distanceUntilNext = GAME_CONSTANTS.minGapColumns;
|
|
3602
|
+
this.isFirstBlockOfRun = true;
|
|
3603
|
+
}
|
|
3604
|
+
/** The agents that walk toward the player; they alternate. */
|
|
3605
|
+
setRivals(rivals) {
|
|
3606
|
+
this.rivals = rivals;
|
|
3607
|
+
}
|
|
3608
|
+
/** Mystery sponsors pop out of the ? boxes (logo or heart); banner and ranking sponsors are drawn by the renderer. Coins stay gold. */
|
|
3609
|
+
setSponsors({ mysterySponsors }) {
|
|
3610
|
+
this.mysterySponsors = mysterySponsors;
|
|
3611
|
+
}
|
|
3612
|
+
/** What the title screen shows on the right: one of each rival, a coin arc and a ? block. */
|
|
3613
|
+
createPreview({ screenWidth }) {
|
|
3614
|
+
const enemyX = screenWidth - PREVIEW_ENEMY_OFFSET;
|
|
3615
|
+
const obstacles = this.rivals.map((rival, i) => new Obstacle({ kind: "bug", x: enemyX + i * PREVIEW_ENEMY_SPACING, ownSpeed: GAME_CONSTANTS.bugWalkSpeed, enemy: CRAB_SPRITES[rival] }));
|
|
3616
|
+
const coins = GAME_CONSTANTS.tokenArc.map((lift, i) => new Token({ x: enemyX - PREVIEW_COIN_OFFSET + i * (TOKEN_SPRITE.width + 1), altitude: PREVIEW_COIN_ALTITUDE + lift }));
|
|
3617
|
+
const sponsor = this.mysterySponsors[0] ?? null;
|
|
3618
|
+
const cardRight = Math.floor((screenWidth + TITLE_CARD.width) / 2);
|
|
3619
|
+
const blockX = enemyX - PREVIEW_BLOCK_OFFSET;
|
|
3620
|
+
if (blockX < cardRight + PREVIEW_CARD_CLEARANCE) return { obstacles, tokens: coins, blocks: [] };
|
|
3621
|
+
const brick = (offset) => new QuestionBlock({ x: blockX + offset * BLOCK_WIDTH, sponsor: null, kind: "brick" });
|
|
3622
|
+
const blocks = [brick(0), new QuestionBlock({ x: blockX + BLOCK_WIDTH, sponsor }), brick(2)];
|
|
3623
|
+
return { obstacles, tokens: coins, blocks };
|
|
3624
|
+
}
|
|
3625
|
+
update({ scroll, runSpeed, distance: distance2, screenWidth }) {
|
|
3626
|
+
this.distanceUntilNext -= scroll;
|
|
3627
|
+
if (this.distanceUntilNext > 0) return EMPTY_RESULT;
|
|
3628
|
+
const spawnX = screenWidth + SPAWN_MARGIN;
|
|
3629
|
+
const obstacles = this.createGroup({ x: spawnX, distance: distance2 });
|
|
3630
|
+
const gap = this.pickGap({ speed: runSpeed });
|
|
3631
|
+
const groupEndX = Math.max(...obstacles.map((obstacle) => obstacle.getX() + obstacle.sprite.width));
|
|
3632
|
+
const rewardX = groupEndX + Math.floor(gap * GAP_FRACTION);
|
|
3633
|
+
this.distanceUntilNext = gap;
|
|
3634
|
+
if (this.random() < GAME_CONSTANTS.blockGroupChance) return { obstacles, tokens: [], blocks: this.createBlocks({ x: rewardX }) };
|
|
3635
|
+
return { obstacles, tokens: this.createTokens({ x: rewardX }), blocks: [] };
|
|
3636
|
+
}
|
|
3637
|
+
pickGap({ speed }) {
|
|
3638
|
+
const spread = GAME_CONSTANTS.maxGapColumns - GAME_CONSTANTS.minGapColumns;
|
|
3639
|
+
return GAME_CONSTANTS.minGapColumns + this.random() * spread + speed * GAME_CONSTANTS.gapPerSpeed;
|
|
3640
|
+
}
|
|
3641
|
+
pickKind({ distance: distance2 }) {
|
|
3642
|
+
const roll = this.random();
|
|
3643
|
+
if (distance2 > GAME_CONSTANTS.ghostMinDistance && roll < GAME_CONSTANTS.ghostChance) return "ghost";
|
|
3644
|
+
if (distance2 > GAME_CONSTANTS.pipeMinDistance && roll < GAME_CONSTANTS.pipeChance) return "pipe";
|
|
3645
|
+
return "bug";
|
|
3646
|
+
}
|
|
3647
|
+
createGroup({ x, distance: distance2 }) {
|
|
3648
|
+
const kind = this.pickKind({ distance: distance2 });
|
|
3649
|
+
if (kind === "ghost") return [this.createGhost({ x })];
|
|
3650
|
+
if (kind === "pipe") return [new Obstacle({ kind, x })];
|
|
3651
|
+
const isPair = distance2 > GAME_CONSTANTS.bugPairMinDistance && this.random() < GAME_CONSTANTS.bugPairChance;
|
|
3652
|
+
const bug = (bugX) => new Obstacle({ kind, x: bugX, ownSpeed: GAME_CONSTANTS.bugWalkSpeed, enemy: CRAB_SPRITES[this.nextRival()] });
|
|
3653
|
+
return isPair ? [bug(x), bug(x + BUG_PAIR_OFFSET)] : [bug(x)];
|
|
3654
|
+
}
|
|
3655
|
+
createGhost({ x }) {
|
|
3656
|
+
const isHigh = this.random() < HIGH_GHOST_CHANCE;
|
|
3657
|
+
const altitude = isHigh ? GAME_CONSTANTS.ghostHighAltitude : GAME_CONSTANTS.ghostMidAltitude;
|
|
3658
|
+
return new Obstacle({ kind: "ghost", x, altitude, ownSpeed: GAME_CONSTANTS.ghostFlySpeed });
|
|
3659
|
+
}
|
|
3660
|
+
nextRival() {
|
|
3661
|
+
const rival = this.rivals[this.rivalIndex % Math.max(1, this.rivals.length)] ?? "codex";
|
|
3662
|
+
this.rivalIndex++;
|
|
3663
|
+
return rival;
|
|
3664
|
+
}
|
|
3665
|
+
/** The very first ? block of a run belongs to the top sponsor; after that blocks are shared by rank. */
|
|
3666
|
+
nextBlockSponsor() {
|
|
3667
|
+
if (!this.isFirstBlockOfRun) return pickRankedSponsor({ sponsors: this.mysterySponsors, roll: this.random() });
|
|
3668
|
+
this.isFirstBlockOfRun = false;
|
|
3669
|
+
return this.mysterySponsors[0] ?? null;
|
|
3670
|
+
}
|
|
3671
|
+
/** Groups of 1, 2, 3 or 5: a 3 has one ? block in the middle, a 5 has two, singles/doubles sometimes one; the rest are bricks. */
|
|
3672
|
+
createBlocks({ x }) {
|
|
3673
|
+
const count = this.pickBlockCount();
|
|
3674
|
+
const questionSlots = this.pickQuestionSlots(count);
|
|
3675
|
+
return Array.from({ length: count }, (_, i) => {
|
|
3676
|
+
if (!questionSlots.has(i)) return new QuestionBlock({ x: x + i * BLOCK_WIDTH, sponsor: null, kind: "brick" });
|
|
3677
|
+
return new QuestionBlock({ x: x + i * BLOCK_WIDTH, sponsor: this.nextBlockSponsor() });
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
pickBlockCount() {
|
|
3681
|
+
let roll = this.random();
|
|
3682
|
+
for (let i = 0; i < GAME_CONSTANTS.blockGroupSizes.length; i++) {
|
|
3683
|
+
roll -= GAME_CONSTANTS.blockGroupWeights[i] ?? 0;
|
|
3684
|
+
if (roll <= 0) return GAME_CONSTANTS.blockGroupSizes[i] ?? 1;
|
|
3685
|
+
}
|
|
3686
|
+
return GAME_CONSTANTS.blockGroupSizes[GAME_CONSTANTS.blockGroupSizes.length - 1] ?? 1;
|
|
3687
|
+
}
|
|
3688
|
+
pickQuestionSlots(count) {
|
|
3689
|
+
if (count >= 5) return /* @__PURE__ */ new Set([1, 3]);
|
|
3690
|
+
if (count === 3) return /* @__PURE__ */ new Set([1]);
|
|
3691
|
+
if (count === 2) return /* @__PURE__ */ new Set([this.random() < 0.5 ? 0 : 1]);
|
|
3692
|
+
return this.random() < GAME_CONSTANTS.singleBlockQuestionChance ? /* @__PURE__ */ new Set([0]) : /* @__PURE__ */ new Set();
|
|
3693
|
+
}
|
|
3694
|
+
createTokens({ x }) {
|
|
3695
|
+
if (this.random() > GAME_CONSTANTS.tokenSpawnChance) return [];
|
|
3696
|
+
const altitudes = GAME_CONSTANTS.tokenAltitudes;
|
|
3697
|
+
const base = altitudes[Math.floor(this.random() * altitudes.length)] ?? 0;
|
|
3698
|
+
const spacing = Math.max(GAME_CONSTANTS.tokenSpacing, TOKEN_SPRITE.width + 1);
|
|
3699
|
+
return Array.from({ length: GAME_CONSTANTS.tokenRowCount }, (_, i) => {
|
|
3700
|
+
const lift = GAME_CONSTANTS.tokenArc[i] ?? 0;
|
|
3701
|
+
return new Token({ x: x + i * spacing, altitude: base + lift });
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3704
|
+
};
|
|
3705
|
+
var BLOCK_WIDTH = GAME_CONSTANTS.blockSize;
|
|
3706
|
+
|
|
3707
|
+
// src/game/game_world.ts
|
|
3708
|
+
var OFFSCREEN_RIGHT_MARGIN = 60;
|
|
3709
|
+
var TITLE_SCROLL_EXTENT = TITLE_CARD.width * 2 + 3;
|
|
3710
|
+
function overlapsX(a, b) {
|
|
3711
|
+
return a.x < b.x + b.width && a.x + a.width > b.x;
|
|
3712
|
+
}
|
|
3713
|
+
var GameWorld = class {
|
|
3714
|
+
player = new Player();
|
|
3715
|
+
spawner;
|
|
3716
|
+
scenery;
|
|
3717
|
+
obstacles = [];
|
|
3718
|
+
tokens = [];
|
|
3719
|
+
blocks = [];
|
|
3720
|
+
powerUps = [];
|
|
3721
|
+
texts = [];
|
|
3722
|
+
debris = [];
|
|
3723
|
+
/** Column of the title plate; it sits in the level and scrolls away once the run starts. */
|
|
3724
|
+
titleX = null;
|
|
3725
|
+
screenWidth;
|
|
3726
|
+
distance = 0;
|
|
3727
|
+
speed = GAME_CONSTANTS.baseSpeed;
|
|
3728
|
+
tokensCollected = 0;
|
|
3729
|
+
bonus = 0;
|
|
3730
|
+
sponsorPickups = 0;
|
|
3731
|
+
lives = GAME_CONSTANTS.startLives;
|
|
3732
|
+
invulnerableTicks = 0;
|
|
3733
|
+
random;
|
|
3734
|
+
constructor({ screenWidth, random = Math.random }) {
|
|
3735
|
+
this.screenWidth = screenWidth;
|
|
3736
|
+
this.random = random;
|
|
3737
|
+
this.spawner = new Spawner({ random });
|
|
3738
|
+
this.scenery = new Scenery({ random });
|
|
3739
|
+
this.reset();
|
|
3740
|
+
}
|
|
3741
|
+
reset() {
|
|
3742
|
+
this.player.reset();
|
|
3743
|
+
this.spawner.reset();
|
|
3744
|
+
this.scenery.reset();
|
|
3745
|
+
this.obstacles = [];
|
|
3746
|
+
this.tokens = [];
|
|
3747
|
+
this.blocks = [];
|
|
3748
|
+
this.powerUps = [];
|
|
3749
|
+
this.texts = [];
|
|
3750
|
+
this.debris = [];
|
|
3751
|
+
this.titleX = Math.floor((this.screenWidth - TITLE_CARD.width) / 2);
|
|
3752
|
+
this.distance = 0;
|
|
3753
|
+
this.speed = GAME_CONSTANTS.baseSpeed;
|
|
3754
|
+
this.tokensCollected = 0;
|
|
3755
|
+
this.bonus = 0;
|
|
3756
|
+
this.sponsorPickups = 0;
|
|
3757
|
+
this.lives = GAME_CONSTANTS.startLives;
|
|
3758
|
+
this.invulnerableTicks = 0;
|
|
3759
|
+
this.seedPreview();
|
|
3760
|
+
}
|
|
3761
|
+
/** Populate the right side so the title screen already shows enemies, coins and a block. */
|
|
3762
|
+
seedPreview() {
|
|
3763
|
+
const preview = this.spawner.createPreview({ screenWidth: this.screenWidth });
|
|
3764
|
+
this.obstacles.push(...preview.obstacles);
|
|
3765
|
+
this.tokens.push(...preview.tokens);
|
|
3766
|
+
this.blocks.push(...preview.blocks);
|
|
3767
|
+
}
|
|
3768
|
+
/** Play as one agent; the other two walk in as enemies. */
|
|
3769
|
+
setAgent(agent) {
|
|
3770
|
+
this.player.setSprites(AGENT_SPRITES[agent]);
|
|
3771
|
+
this.spawner.setRivals(AGENT_KINDS.filter((kind) => kind !== agent));
|
|
3772
|
+
this.reset();
|
|
3773
|
+
}
|
|
3774
|
+
setSponsors(sponsors) {
|
|
3775
|
+
this.spawner.setSponsors({ mysterySponsors: sponsors.filter((sponsor) => sponsor.tier === "mystery") });
|
|
3776
|
+
}
|
|
3777
|
+
get score() {
|
|
3778
|
+
return Math.floor(this.distance * GAME_CONSTANTS.scorePerColumn) + this.tokensCollected * GAME_CONSTANTS.coinPoints + this.bonus;
|
|
3779
|
+
}
|
|
3780
|
+
getDistance() {
|
|
3781
|
+
return this.distance;
|
|
3782
|
+
}
|
|
3783
|
+
getSpeed() {
|
|
3784
|
+
return this.speed;
|
|
3785
|
+
}
|
|
3786
|
+
getTokensCollected() {
|
|
3787
|
+
return this.tokensCollected;
|
|
3788
|
+
}
|
|
3789
|
+
getBonus() {
|
|
3790
|
+
return this.bonus;
|
|
3791
|
+
}
|
|
3792
|
+
getLives() {
|
|
3793
|
+
return this.lives;
|
|
3794
|
+
}
|
|
3795
|
+
/** Just lost a life: enemies pass through and the mascot blinks. */
|
|
3796
|
+
get isInvulnerable() {
|
|
3797
|
+
return this.invulnerableTicks > 0;
|
|
3798
|
+
}
|
|
3799
|
+
getInvulnerableTicks() {
|
|
3800
|
+
return this.invulnerableTicks;
|
|
3801
|
+
}
|
|
3802
|
+
getObstacles() {
|
|
3803
|
+
return this.obstacles;
|
|
3804
|
+
}
|
|
3805
|
+
getTokens() {
|
|
3806
|
+
return this.tokens;
|
|
3807
|
+
}
|
|
3808
|
+
getBlocks() {
|
|
3809
|
+
return this.blocks;
|
|
3810
|
+
}
|
|
3811
|
+
getPowerUps() {
|
|
3812
|
+
return this.powerUps;
|
|
3813
|
+
}
|
|
3814
|
+
getTexts() {
|
|
3815
|
+
return this.texts;
|
|
3816
|
+
}
|
|
3817
|
+
getDebris() {
|
|
3818
|
+
return this.debris;
|
|
3819
|
+
}
|
|
3820
|
+
getTitleX() {
|
|
3821
|
+
return this.titleX;
|
|
3822
|
+
}
|
|
3823
|
+
getScenery() {
|
|
3824
|
+
return this.scenery.getItems();
|
|
3825
|
+
}
|
|
3826
|
+
setScreenWidth(screenWidth) {
|
|
3827
|
+
this.screenWidth = screenWidth;
|
|
3828
|
+
}
|
|
3829
|
+
/** Direct placement, used by tests and scripted moments. */
|
|
3830
|
+
addObstacle(obstacle) {
|
|
3831
|
+
this.obstacles.push(obstacle);
|
|
3832
|
+
}
|
|
3833
|
+
addBlock(block) {
|
|
3834
|
+
this.blocks.push(block);
|
|
3835
|
+
}
|
|
3836
|
+
step() {
|
|
3837
|
+
const runSpeed = computeSpeed({ distance: this.distance });
|
|
3838
|
+
this.speed = this.clampToPipes(this.player.advanceHorizontal({ runSpeed }));
|
|
3839
|
+
this.distance += this.speed;
|
|
3840
|
+
this.advanceEntities();
|
|
3841
|
+
this.spawnEntities();
|
|
3842
|
+
this.player.setFloor(this.computeFloor());
|
|
3843
|
+
this.hitBlocks();
|
|
3844
|
+
this.player.update();
|
|
3845
|
+
const collectedNow = this.collectTokens();
|
|
3846
|
+
this.collectPowerUps();
|
|
3847
|
+
if (this.invulnerableTicks > 0) this.invulnerableTicks--;
|
|
3848
|
+
const hasCollided = this.resolveEnemies();
|
|
3849
|
+
const isGameOver = hasCollided && this.loseLife();
|
|
3850
|
+
if (isGameOver) this.player.markDead();
|
|
3851
|
+
return { hasCollided, isGameOver, collectedNow };
|
|
3852
|
+
}
|
|
3853
|
+
/** Costs one life; with lives left the mascot hops back and blinks for a moment. Returns true when none remain. */
|
|
3854
|
+
loseLife() {
|
|
3855
|
+
this.lives = Math.max(0, this.lives - 1);
|
|
3856
|
+
if (this.lives <= 0) return true;
|
|
3857
|
+
this.invulnerableTicks = GAME_CONSTANTS.hurtInvulnerableTicks;
|
|
3858
|
+
this.player.bounce();
|
|
3859
|
+
this.texts.push(new FloatingText({ text: "-1 LIFE", x: this.player.getScreenX(), altitude: this.player.getAltitude() + 10 }));
|
|
3860
|
+
return false;
|
|
3861
|
+
}
|
|
3862
|
+
advanceEntities() {
|
|
3863
|
+
const scroll = this.speed;
|
|
3864
|
+
this.obstacles.forEach((obstacle) => obstacle.update({ scroll }));
|
|
3865
|
+
this.tokens.forEach((token) => token.update({ scroll }));
|
|
3866
|
+
this.blocks.forEach((block) => block.update({ scroll }));
|
|
3867
|
+
this.powerUps.forEach((powerUp) => powerUp.update({ scroll }));
|
|
3868
|
+
this.texts.forEach((text) => text.update({ scroll }));
|
|
3869
|
+
this.debris.forEach((piece) => piece.update({ scroll }));
|
|
3870
|
+
if (this.titleX !== null) this.titleX = this.titleX + TITLE_SCROLL_EXTENT < 0 ? null : this.titleX - scroll;
|
|
3871
|
+
this.scenery.update({ scroll, screenWidth: this.screenWidth });
|
|
3872
|
+
this.bounceOffPipes();
|
|
3873
|
+
this.obstacles = this.obstacles.filter((obstacle) => !obstacle.isOffscreen && !obstacle.isGone && obstacle.getX() < this.screenWidth + OFFSCREEN_RIGHT_MARGIN);
|
|
3874
|
+
this.tokens = this.tokens.filter((token) => !token.isOffscreen && !token.isCollected);
|
|
3875
|
+
this.blocks = this.blocks.filter((block) => !block.isOffscreen && !block.isGone);
|
|
3876
|
+
this.debris = this.debris.filter((piece) => !piece.isGone);
|
|
3877
|
+
this.powerUps = this.powerUps.filter((powerUp) => !powerUp.isOffscreen);
|
|
3878
|
+
this.texts = this.texts.filter((text) => !text.isGone);
|
|
3879
|
+
}
|
|
3880
|
+
spawnEntities() {
|
|
3881
|
+
const spawned = this.spawner.update({ scroll: this.speed, runSpeed: computeSpeed({ distance: this.distance }), distance: this.distance, screenWidth: this.screenWidth });
|
|
3882
|
+
this.obstacles.push(...spawned.obstacles);
|
|
3883
|
+
this.tokens.push(...spawned.tokens);
|
|
3884
|
+
this.blocks.push(...spawned.blocks);
|
|
3885
|
+
}
|
|
3886
|
+
/** Highest block top under the player that they are above (within landing tolerance), else the ground. */
|
|
3887
|
+
computeFloor() {
|
|
3888
|
+
const bounds = this.player.getBounds();
|
|
3889
|
+
const altitude = this.player.getAltitude();
|
|
3890
|
+
const platforms = [...this.blocks.map((block) => block.getBounds()), ...this.pipes().map((pipe) => pipe.getBounds())];
|
|
3891
|
+
const tops = platforms.filter((platform) => overlapsX(bounds, platform) && altitude >= platform.y + platform.height - GAME_CONSTANTS.landingTolerance).map((platform) => platform.y + platform.height);
|
|
3892
|
+
return Math.max(0, ...tops);
|
|
3893
|
+
}
|
|
3894
|
+
/** Walkers and mushrooms stay between the pipes, pacing back and forth. */
|
|
3895
|
+
bounceOffPipes() {
|
|
3896
|
+
const pipes = this.pipes().map((pipe) => pipe.getBounds());
|
|
3897
|
+
const walkers = this.obstacles.filter((obstacle) => obstacle.kind !== "pipe" && !obstacle.isSquished);
|
|
3898
|
+
pipes.forEach((pipe) => {
|
|
3899
|
+
walkers.filter((walker) => hasCollision({ a: walker.getBounds(), b: pipe })).forEach((walker) => walker.bounceOff(pipe));
|
|
3900
|
+
this.powerUps.filter((powerUp) => hasCollision({ a: powerUp.getBounds(), b: pipe })).forEach((powerUp) => powerUp.bounceOff(pipe));
|
|
3901
|
+
});
|
|
3902
|
+
}
|
|
3903
|
+
pipes() {
|
|
3904
|
+
return this.obstacles.filter((obstacle) => obstacle.kind === "pipe");
|
|
3905
|
+
}
|
|
3906
|
+
/** Pipes are solid: running into one just stops the scroll (Mario bumps into the pipe), it never hurts. */
|
|
3907
|
+
clampToPipes(scroll) {
|
|
3908
|
+
if (scroll <= 0) return scroll;
|
|
3909
|
+
const bounds = this.player.getBounds();
|
|
3910
|
+
const playerRight = bounds.x + bounds.width;
|
|
3911
|
+
const gaps = this.pipes().map((pipe) => pipe.getBounds()).filter((pipe) => bounds.y < pipe.height - GAME_CONSTANTS.landingTolerance && pipe.x + pipe.width > bounds.x).map((pipe) => Math.max(0, pipe.x - playerRight));
|
|
3912
|
+
return Math.min(scroll, ...gaps);
|
|
3913
|
+
}
|
|
3914
|
+
hitBlocks() {
|
|
3915
|
+
if (this.player.getVelocity() <= 0) return;
|
|
3916
|
+
const bounds = this.player.getBounds();
|
|
3917
|
+
const top = bounds.y + bounds.height;
|
|
3918
|
+
const hit = this.blocks.find((block) => {
|
|
3919
|
+
const isUnder = top >= block.getAltitude() - 1 && top <= block.getAltitude() + GAME_CONSTANTS.blockHitTolerance;
|
|
3920
|
+
return block.isFresh && isUnder && overlapsX(bounds, block.getBounds());
|
|
3921
|
+
});
|
|
3922
|
+
if (hit === void 0) return;
|
|
3923
|
+
hit.hit();
|
|
3924
|
+
this.player.bonk();
|
|
3925
|
+
if (hit.kind === "brick") return this.smashBrick(hit);
|
|
3926
|
+
this.powerUps.push(this.createPowerUp(hit));
|
|
3927
|
+
}
|
|
3928
|
+
/** A bonked brick bursts into four chunks and pays a small bonus. */
|
|
3929
|
+
smashBrick(brick) {
|
|
3930
|
+
const bounds = brick.getBounds();
|
|
3931
|
+
const corners = [
|
|
3932
|
+
{ dx: 0, vx: -0.7, vy: 3.2 },
|
|
3933
|
+
{ dx: bounds.width - 2, vx: 0.7, vy: 3.2 },
|
|
3934
|
+
{ dx: 1, vx: -0.4, vy: 2.2 },
|
|
3935
|
+
{ dx: bounds.width - 3, vx: 0.4, vy: 2.2 }
|
|
3936
|
+
];
|
|
3937
|
+
corners.forEach(({ dx, vx, vy }) => this.debris.push(new Debris({ x: bounds.x + dx, altitude: bounds.y + bounds.height / 2, velocityX: vx, velocityY: vy })));
|
|
3938
|
+
this.bonus += GAME_CONSTANTS.brickBonus;
|
|
3939
|
+
}
|
|
3940
|
+
createPowerUp(block) {
|
|
3941
|
+
const sponsor = block.sponsor;
|
|
3942
|
+
if (this.random() < GAME_CONSTANTS.oneUpChance) return this.createOneUp(block);
|
|
3943
|
+
const isText = sponsor !== null && sponsor.style === "text";
|
|
3944
|
+
return new PowerUp({
|
|
3945
|
+
sprite: sponsor === null || isText ? WORLD_SPRITES.mushroom : toSponsorSprite(sponsor),
|
|
3946
|
+
textFace: isText && sponsor !== null ? toTextFace(sponsor) : null,
|
|
3947
|
+
link: sponsor?.url ?? null,
|
|
3948
|
+
label: sponsor?.name ?? "mushroom",
|
|
3949
|
+
x: block.getX(),
|
|
3950
|
+
altitude: block.getAltitude()
|
|
3951
|
+
});
|
|
3952
|
+
}
|
|
3953
|
+
/** Some blocks drop a heart (1-UP) instead; a sponsor's block still gets the credit and the link. */
|
|
3954
|
+
createOneUp(block) {
|
|
3955
|
+
return new PowerUp({
|
|
3956
|
+
kind: "life",
|
|
3957
|
+
sprite: WORLD_SPRITES.heart,
|
|
3958
|
+
link: block.sponsor?.url ?? null,
|
|
3959
|
+
label: block.sponsor?.name ?? "1UP",
|
|
3960
|
+
x: block.getX(),
|
|
3961
|
+
altitude: block.getAltitude()
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
collectTokens() {
|
|
3965
|
+
const bounds = this.player.getBounds();
|
|
3966
|
+
const touched = this.tokens.filter((token) => hasCollision({ a: bounds, b: token.getBounds() }));
|
|
3967
|
+
touched.forEach((token) => token.collect());
|
|
3968
|
+
this.tokensCollected += touched.length;
|
|
3969
|
+
return touched.length;
|
|
3970
|
+
}
|
|
3971
|
+
collectPowerUps() {
|
|
3972
|
+
const bounds = this.player.getBounds();
|
|
3973
|
+
const touched = this.powerUps.filter((powerUp) => !powerUp.isEmerging && hasCollision({ a: bounds, b: powerUp.getBounds() }));
|
|
3974
|
+
if (touched.length === 0) return;
|
|
3975
|
+
this.powerUps = this.powerUps.filter((powerUp) => !touched.includes(powerUp));
|
|
3976
|
+
touched.forEach((powerUp) => this.collectPowerUp(powerUp));
|
|
3977
|
+
}
|
|
3978
|
+
/** A sponsor logo pays a growing bonus (+300, +400, ...) shown as "+300 by FIGMA"; a plain mushroom pays +200. */
|
|
3979
|
+
collectPowerUp(powerUp) {
|
|
3980
|
+
if (powerUp.kind === "life") return this.gainLife(powerUp);
|
|
3981
|
+
if (powerUp.link === null) return this.reward({ points: GAME_CONSTANTS.powerUpBonus, label: "" });
|
|
3982
|
+
const points = GAME_CONSTANTS.sponsorBonusStart + GAME_CONSTANTS.sponsorBonusStep * this.sponsorPickups;
|
|
3983
|
+
this.sponsorPickups++;
|
|
3984
|
+
this.reward({ points, label: `by ${powerUp.label.toUpperCase()}` });
|
|
3985
|
+
}
|
|
3986
|
+
gainLife(powerUp) {
|
|
3987
|
+
this.lives = Math.min(GAME_CONSTANTS.maxLives, this.lives + 1);
|
|
3988
|
+
const credit = powerUp.link === null ? "" : ` by ${powerUp.label.toUpperCase()}`;
|
|
3989
|
+
this.texts.push(new FloatingText({ text: `+1 LIFE${credit}`, x: this.player.getScreenX(), altitude: this.player.getAltitude() + 10 }));
|
|
3990
|
+
}
|
|
3991
|
+
/** Falling onto a bug squishes it for a bonus; any other contact ends the run. */
|
|
3992
|
+
resolveEnemies() {
|
|
3993
|
+
const bounds = this.player.getBounds();
|
|
3994
|
+
const touching = this.obstacles.filter((obstacle) => obstacle.kind !== "pipe" && !obstacle.isSquished && hasCollision({ a: bounds, b: obstacle.getBounds() }));
|
|
3995
|
+
const stomped = touching.filter((obstacle) => this.isStomping({ bounds, obstacle }));
|
|
3996
|
+
stomped.forEach((obstacle) => {
|
|
3997
|
+
obstacle.squish();
|
|
3998
|
+
this.reward({ points: GAME_CONSTANTS.stompBonus, label: "" });
|
|
3999
|
+
});
|
|
4000
|
+
if (stomped.length > 0) this.player.bounce();
|
|
4001
|
+
return !this.isInvulnerable && touching.length > stomped.length;
|
|
4002
|
+
}
|
|
4003
|
+
isStomping({ bounds, obstacle }) {
|
|
4004
|
+
const enemyTop = obstacle.getBounds().y + obstacle.getBounds().height;
|
|
4005
|
+
return obstacle.isStompable && this.player.isFalling && bounds.y >= enemyTop - GAME_CONSTANTS.stompTolerance;
|
|
4006
|
+
}
|
|
4007
|
+
reward({ points, label }) {
|
|
4008
|
+
this.bonus += points;
|
|
4009
|
+
const text = label.length === 0 ? `+${points}` : `+${points} ${label}`;
|
|
4010
|
+
this.texts.push(new FloatingText({ text, x: this.player.getScreenX(), altitude: this.player.getAltitude() + 10 }));
|
|
4011
|
+
}
|
|
4012
|
+
};
|
|
4013
|
+
|
|
4014
|
+
// src/game/game.ts
|
|
4015
|
+
var RANKING_OFF = { state: "off", result: null };
|
|
4016
|
+
var Game = class {
|
|
4017
|
+
screen;
|
|
4018
|
+
input;
|
|
4019
|
+
scoreStore;
|
|
4020
|
+
claudeWatcher;
|
|
4021
|
+
onQuit;
|
|
4022
|
+
playerName;
|
|
4023
|
+
agent;
|
|
4024
|
+
leaderboard;
|
|
4025
|
+
sponsorSource;
|
|
4026
|
+
menu;
|
|
4027
|
+
renderer;
|
|
4028
|
+
ranking = RANKING_OFF;
|
|
4029
|
+
sponsors = [];
|
|
4030
|
+
world;
|
|
4031
|
+
phase = "ready";
|
|
4032
|
+
mode;
|
|
4033
|
+
scores;
|
|
4034
|
+
banner = null;
|
|
4035
|
+
isPausedByClaude = false;
|
|
4036
|
+
lastClaudeStatus = "";
|
|
4037
|
+
tickCount = 0;
|
|
4038
|
+
timer = null;
|
|
4039
|
+
isStopped = false;
|
|
4040
|
+
constructor({ screen, input, scoreStore, claudeWatcher, onQuit, playerName, agent = "claude", leaderboard = null, sponsorSource = null, settings = null, random = Math.random }) {
|
|
4041
|
+
this.screen = screen;
|
|
4042
|
+
this.input = input;
|
|
4043
|
+
this.scoreStore = scoreStore;
|
|
4044
|
+
this.claudeWatcher = claudeWatcher;
|
|
4045
|
+
this.onQuit = onQuit;
|
|
4046
|
+
this.playerName = playerName;
|
|
4047
|
+
this.agent = agent;
|
|
4048
|
+
this.leaderboard = leaderboard;
|
|
4049
|
+
this.sponsorSource = sponsorSource;
|
|
4050
|
+
this.menu = settings === null ? null : new SettingsMenu({ store: settings });
|
|
4051
|
+
this.mode = settings === null ? "vibe" : settings.load().mode;
|
|
4052
|
+
const size = screen.getSize();
|
|
4053
|
+
this.renderer = new GameRenderer({ size });
|
|
4054
|
+
this.world = new GameWorld({ screenWidth: this.renderer.getLayout().columns, random });
|
|
4055
|
+
this.world.setAgent(agent);
|
|
4056
|
+
this.scores = scoreStore.load();
|
|
4057
|
+
}
|
|
4058
|
+
getPhase() {
|
|
4059
|
+
return this.phase;
|
|
4060
|
+
}
|
|
4061
|
+
getWorld() {
|
|
4062
|
+
return this.world;
|
|
4063
|
+
}
|
|
4064
|
+
getMode() {
|
|
4065
|
+
return this.mode;
|
|
4066
|
+
}
|
|
4067
|
+
/** PRO CODER only lets the mascot run while the agent is busy with a prompt. */
|
|
4068
|
+
get canPlay() {
|
|
4069
|
+
return this.mode === "vibe" || isAgentWorking({ record: this.claudeWatcher.getCurrent() });
|
|
4070
|
+
}
|
|
4071
|
+
/** The line the overlay shows instead of "press SPACE" while PRO CODER is waiting for the agent. */
|
|
4072
|
+
get lockMessage() {
|
|
4073
|
+
if (this.canPlay) return null;
|
|
4074
|
+
const agent = AGENT_LABELS[this.claudeWatcher.getCurrent()?.agent ?? this.agent];
|
|
4075
|
+
return `${GAME_MODE_LABELS.pro}: plays only while ${agent} is working`;
|
|
4076
|
+
}
|
|
4077
|
+
start() {
|
|
4078
|
+
this.screen.enter();
|
|
4079
|
+
this.input.start(this.handleAction);
|
|
4080
|
+
this.screen.onResize(this.handleResize);
|
|
4081
|
+
this.timer = setInterval(() => this.tick(), GAME_CONSTANTS.tickMs);
|
|
4082
|
+
this.loadRanking();
|
|
4083
|
+
this.loadSponsors();
|
|
4084
|
+
this.render();
|
|
4085
|
+
}
|
|
4086
|
+
loadSponsors() {
|
|
4087
|
+
if (this.sponsorSource === null) return;
|
|
4088
|
+
this.sponsorSource.loadSponsors().then((sponsors) => {
|
|
4089
|
+
this.sponsors = sponsors;
|
|
4090
|
+
this.world.setSponsors(sponsors);
|
|
4091
|
+
}).catch(() => {
|
|
4092
|
+
this.sponsors = [];
|
|
4093
|
+
});
|
|
4094
|
+
}
|
|
4095
|
+
loadRanking() {
|
|
4096
|
+
if (this.leaderboard === null) return;
|
|
4097
|
+
this.ranking = { state: "pending", result: null };
|
|
4098
|
+
this.leaderboard.fetchRanking({ limit: GAME_CONSTANTS.rankingPanelLimit, mode: this.mode, score: this.mode === "pro" ? this.scores.proHighScore : this.scores.highScore }).then((result) => {
|
|
4099
|
+
this.ranking = { state: "ready", result };
|
|
4100
|
+
}).catch(() => {
|
|
4101
|
+
this.ranking = { state: "failed", result: null };
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
stop() {
|
|
4105
|
+
if (this.isStopped) return;
|
|
4106
|
+
this.isStopped = true;
|
|
4107
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
4108
|
+
this.input.stop();
|
|
4109
|
+
this.screen.exit();
|
|
4110
|
+
}
|
|
4111
|
+
tick() {
|
|
4112
|
+
this.tickCount++;
|
|
4113
|
+
this.pollClaude();
|
|
4114
|
+
this.updateBanner();
|
|
4115
|
+
if (this.phase === "running") this.stepWorld();
|
|
4116
|
+
this.render();
|
|
4117
|
+
}
|
|
4118
|
+
handleAction = (action) => {
|
|
4119
|
+
if (action === "quit") return this.quit();
|
|
4120
|
+
if (action === "settings") return this.toggleSettings();
|
|
4121
|
+
if (this.menu !== null && this.menu.isOpen) return this.handleMenuAction(action);
|
|
4122
|
+
if (action === "up") return this.handleAction("jump");
|
|
4123
|
+
if (action === "down") return this.handleAction("duck");
|
|
4124
|
+
if (action === "pause") return this.togglePause();
|
|
4125
|
+
if (action === "restart") return this.restart();
|
|
4126
|
+
if (action === "duck" && this.phase === "running") return this.world.player.duck();
|
|
4127
|
+
if (action === "hold_support") return this.world.player.enableHoldMode();
|
|
4128
|
+
if (action === "hold_reliable") return this.world.player.enableHoldMode({ reliable: true });
|
|
4129
|
+
if (action === "left_release") return this.world.player.releaseLeft();
|
|
4130
|
+
if (action === "right_release") return this.world.player.releaseRight();
|
|
4131
|
+
if (action === "left" && this.phase === "running") return this.world.player.moveLeft();
|
|
4132
|
+
if (action === "right" && this.phase === "running") return this.world.player.moveRight();
|
|
4133
|
+
if (action === "right" && this.phase === "ready") return this.beginRun();
|
|
4134
|
+
if (action !== "jump") return;
|
|
4135
|
+
if (this.phase === "paused") return this.togglePause();
|
|
4136
|
+
if (this.phase === "ready") return this.beginRun();
|
|
4137
|
+
if (this.phase === "over") return this.restart();
|
|
4138
|
+
if (this.phase === "running") this.world.player.jump();
|
|
4139
|
+
};
|
|
4140
|
+
/** The settings panel replaces the title plate on the start and pause screens. */
|
|
4141
|
+
toggleSettings() {
|
|
4142
|
+
if (this.menu === null || this.phase !== "ready" && this.phase !== "paused") return;
|
|
4143
|
+
this.menu.toggle();
|
|
4144
|
+
}
|
|
4145
|
+
handleMenuAction(action) {
|
|
4146
|
+
if (this.menu === null) return;
|
|
4147
|
+
if (action === "up") return this.menu.moveSelection(-1);
|
|
4148
|
+
if (action === "down") return this.menu.moveSelection(1);
|
|
4149
|
+
if (action === "left") return this.applySetting(-1);
|
|
4150
|
+
if (action === "right" || action === "jump") return this.applySetting(1);
|
|
4151
|
+
}
|
|
4152
|
+
applySetting(direction) {
|
|
4153
|
+
if (this.menu === null) return;
|
|
4154
|
+
const config = this.menu.change(direction);
|
|
4155
|
+
if (config.mode !== this.mode) {
|
|
4156
|
+
this.mode = config.mode;
|
|
4157
|
+
this.loadRanking();
|
|
4158
|
+
}
|
|
4159
|
+
if (config.character !== "auto") this.world.setAgent(config.character);
|
|
4160
|
+
}
|
|
4161
|
+
handleResize = () => {
|
|
4162
|
+
const size = this.screen.getSize();
|
|
4163
|
+
this.renderer.resize({ size });
|
|
4164
|
+
this.world.setScreenWidth(this.renderer.getLayout().columns);
|
|
4165
|
+
this.render();
|
|
4166
|
+
};
|
|
4167
|
+
stepWorld() {
|
|
4168
|
+
const result = this.world.step();
|
|
4169
|
+
if (!result.isGameOver) return;
|
|
4170
|
+
this.phase = "over";
|
|
4171
|
+
this.scores = this.scoreStore.recordGame({ score: this.world.score, tokens: this.world.getTokensCollected(), mode: this.mode });
|
|
4172
|
+
this.submitScore();
|
|
4173
|
+
}
|
|
4174
|
+
/** Fire-and-forget: the loop never waits on the network, the overlay reflects whatever came back. */
|
|
4175
|
+
submitScore() {
|
|
4176
|
+
if (this.leaderboard === null) return;
|
|
4177
|
+
this.ranking = { state: "pending", result: this.ranking.result };
|
|
4178
|
+
this.leaderboard.submit({ name: this.playerName, record: this.scores, mode: this.mode }).then((result) => {
|
|
4179
|
+
this.ranking = { state: "ready", result };
|
|
4180
|
+
}).catch(() => {
|
|
4181
|
+
this.ranking = { state: "failed", result: null };
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
pollClaude() {
|
|
4185
|
+
if (this.tickCount % GAME_CONSTANTS.claudePollTicks !== 0) return;
|
|
4186
|
+
const change = this.claudeWatcher.pollChange();
|
|
4187
|
+
if (change === null) return;
|
|
4188
|
+
if (change.status !== this.lastClaudeStatus) this.banner = createBanner(change);
|
|
4189
|
+
this.lastClaudeStatus = change.status;
|
|
4190
|
+
const needsUser = change.status !== "working";
|
|
4191
|
+
if (needsUser && this.phase === "running") this.pauseForClaude();
|
|
4192
|
+
if (change.status === "working" && this.isPausedByClaude) this.togglePause();
|
|
4193
|
+
}
|
|
4194
|
+
pauseForClaude() {
|
|
4195
|
+
this.phase = "paused";
|
|
4196
|
+
this.isPausedByClaude = true;
|
|
4197
|
+
}
|
|
4198
|
+
updateBanner() {
|
|
4199
|
+
if (this.banner === null) return;
|
|
4200
|
+
const ticksLeft = this.banner.ticksLeft - 1;
|
|
4201
|
+
this.banner = ticksLeft > 0 ? { ...this.banner, ticksLeft } : null;
|
|
4202
|
+
}
|
|
4203
|
+
beginRun() {
|
|
4204
|
+
if (!this.canPlay) return;
|
|
4205
|
+
this.world.reset();
|
|
4206
|
+
this.phase = "running";
|
|
4207
|
+
}
|
|
4208
|
+
restart() {
|
|
4209
|
+
if (this.phase === "ready") return;
|
|
4210
|
+
this.beginRun();
|
|
4211
|
+
}
|
|
4212
|
+
togglePause() {
|
|
4213
|
+
if (this.phase === "paused" && !this.canPlay) return;
|
|
4214
|
+
this.isPausedByClaude = false;
|
|
4215
|
+
if (this.phase === "running") this.phase = "paused";
|
|
4216
|
+
else if (this.phase === "paused") this.phase = "running";
|
|
4217
|
+
}
|
|
4218
|
+
quit() {
|
|
4219
|
+
this.stop();
|
|
4220
|
+
this.onQuit();
|
|
4221
|
+
}
|
|
4222
|
+
render() {
|
|
4223
|
+
if (this.isStopped) return;
|
|
4224
|
+
const lines = this.renderer.render({
|
|
4225
|
+
world: this.world,
|
|
4226
|
+
phase: this.phase,
|
|
4227
|
+
scores: this.scores,
|
|
4228
|
+
playerName: this.playerName,
|
|
4229
|
+
agent: this.agent,
|
|
4230
|
+
mode: this.mode,
|
|
4231
|
+
lockMessage: this.lockMessage,
|
|
4232
|
+
ranking: this.ranking,
|
|
4233
|
+
sponsors: this.sponsors,
|
|
4234
|
+
settings: this.menu !== null && this.menu.isOpen ? this.menu.getView() : null,
|
|
4235
|
+
claude: this.claudeWatcher.getCurrent(),
|
|
4236
|
+
banner: this.banner,
|
|
4237
|
+
tickCount: this.tickCount
|
|
4238
|
+
});
|
|
4239
|
+
this.screen.render(lines);
|
|
4240
|
+
}
|
|
4241
|
+
};
|
|
4242
|
+
|
|
4243
|
+
// src/game/create_game.ts
|
|
4244
|
+
function createGame({ onQuit, playerName, agent }) {
|
|
4245
|
+
return new Game({
|
|
4246
|
+
playerName,
|
|
4247
|
+
agent,
|
|
4248
|
+
screen: new TerminalScreen(),
|
|
4249
|
+
input: new CompositeInput(process.platform === "darwin" ? [new Keyboard(), new KeyStateWatcher()] : [new Keyboard()]),
|
|
4250
|
+
scoreStore: new ScoreStore({ path: DARIO_PATHS.scoresFile }),
|
|
4251
|
+
claudeWatcher: new ClaudeStatusWatcher({ path: DARIO_PATHS.claudeStatusFile }),
|
|
4252
|
+
leaderboard: createLeaderboard(),
|
|
4253
|
+
sponsorSource: createSponsorService(),
|
|
4254
|
+
settings: new ConfigStore({ path: DARIO_PATHS.configFile }),
|
|
4255
|
+
onQuit
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
// src/player/ensure_player_name.ts
|
|
4260
|
+
async function ensurePlayerName() {
|
|
4261
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
4262
|
+
const saved = store.load().playerName;
|
|
4263
|
+
if (PlayerName.isValid(saved)) return saved;
|
|
4264
|
+
const chosen = await askPlayerName(detectPlayerName());
|
|
4265
|
+
store.update({ playerName: chosen.value });
|
|
4266
|
+
console.log(`Welcome, ${chosen.value}! Change it any time with "dario name <new name>".`);
|
|
4267
|
+
return chosen.value;
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
// src/cli/commands/play_command.ts
|
|
4271
|
+
var EXIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
4272
|
+
function registerCleanup({ game, lock }) {
|
|
4273
|
+
const cleanup = () => {
|
|
4274
|
+
game.stop();
|
|
4275
|
+
lock.release();
|
|
4276
|
+
};
|
|
4277
|
+
EXIT_SIGNALS.forEach((signal) => process.on(signal, () => {
|
|
4278
|
+
cleanup();
|
|
4279
|
+
process.exit(0);
|
|
4280
|
+
}));
|
|
4281
|
+
process.on("exit", cleanup);
|
|
4282
|
+
}
|
|
4283
|
+
function runGame({ lock, playerName, agent }) {
|
|
4284
|
+
return new Promise((resolve2) => {
|
|
4285
|
+
const game = createGame({ playerName, agent, onQuit: () => {
|
|
4286
|
+
lock.release();
|
|
4287
|
+
resolve2(0);
|
|
4288
|
+
} });
|
|
4289
|
+
registerCleanup({ game, lock });
|
|
4290
|
+
game.start();
|
|
4291
|
+
});
|
|
4292
|
+
}
|
|
4293
|
+
var PLAY_COMMAND = {
|
|
4294
|
+
name: "play",
|
|
4295
|
+
aliases: ["run", "start"],
|
|
4296
|
+
usage: "dario play [--as claude|codex|grok]",
|
|
4297
|
+
description: "Play Dario in the current terminal (needs an interactive TTY).",
|
|
4298
|
+
async execute(args) {
|
|
4299
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
4300
|
+
console.error('dario play needs an interactive terminal. Use "dario launch" to open one.');
|
|
4301
|
+
return 1;
|
|
4302
|
+
}
|
|
4303
|
+
const lock = new ProcessLock({ path: DARIO_PATHS.pidFile });
|
|
4304
|
+
if (!lock.acquire()) {
|
|
4305
|
+
console.error(`Dario is already running (pid ${lock.readPid()}). Run "dario stop" first.`);
|
|
4306
|
+
return 1;
|
|
4307
|
+
}
|
|
4308
|
+
await runFirstRunSetup();
|
|
4309
|
+
const playerName = await ensurePlayerName();
|
|
4310
|
+
const configured = new ConfigStore({ path: DARIO_PATHS.configFile }).load().character;
|
|
4311
|
+
const agent = resolveAgent({ args, configured, env: process.env });
|
|
4312
|
+
return runGame({ lock, playerName, agent });
|
|
4313
|
+
}
|
|
4314
|
+
};
|
|
4315
|
+
|
|
4316
|
+
// src/cli/commands/ranking_command.ts
|
|
4317
|
+
var DEFAULT_LIMIT = 20;
|
|
4318
|
+
var NAME_WIDTH2 = 22;
|
|
4319
|
+
function renderRow({ entry, index, uid }) {
|
|
4320
|
+
const marker = entry.uid === uid ? ">" : " ";
|
|
4321
|
+
return `${marker} #${String(index + 1).padEnd(3)} ${entry.name.padEnd(NAME_WIDTH2)} ${String(entry.highScore).padStart(6)} tokens ${entry.bestTokens}`;
|
|
4322
|
+
}
|
|
4323
|
+
function renderRanking({ result, playerName }) {
|
|
4324
|
+
const rows = result.top.map((entry, index) => renderRow({ entry, index, uid: result.uid }));
|
|
4325
|
+
const mine = result.rank === null ? `${playerName}: no score submitted yet, play a game!` : `${playerName}: rank #${result.rank}`;
|
|
4326
|
+
return ["Dario global ranking", ...rows, "", mine].join("\n");
|
|
4327
|
+
}
|
|
4328
|
+
var RANKING_COMMAND = {
|
|
4329
|
+
name: "ranking",
|
|
4330
|
+
aliases: ["rank", "leaderboard", "top"],
|
|
4331
|
+
usage: "dario ranking [--pro] [--json]",
|
|
4332
|
+
description: "Show the global leaderboard (VIBE, or PRO with --pro) and your rank.",
|
|
4333
|
+
async execute(args) {
|
|
4334
|
+
const playerName = new ConfigStore({ path: DARIO_PATHS.configFile }).load().playerName || "you";
|
|
4335
|
+
try {
|
|
4336
|
+
const result = await createLeaderboard().fetchRanking({ limit: DEFAULT_LIMIT, mode: args.includes("--pro") ? "pro" : "vibe" });
|
|
4337
|
+
console.log(args.includes("--json") ? JSON.stringify(result) : renderRanking({ result, playerName }));
|
|
4338
|
+
return 0;
|
|
4339
|
+
} catch (err) {
|
|
4340
|
+
console.error(`Could not load the ranking: ${err instanceof Error ? err.message : String(err)}`);
|
|
4341
|
+
return 1;
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
};
|
|
4345
|
+
|
|
4346
|
+
// src/cli/commands/scores_command.ts
|
|
4347
|
+
function formatDate(timestamp) {
|
|
4348
|
+
return timestamp === 0 ? "never" : new Date(timestamp).toLocaleString();
|
|
4349
|
+
}
|
|
4350
|
+
function renderScores(record) {
|
|
4351
|
+
return [
|
|
4352
|
+
"Dario scores",
|
|
4353
|
+
` high score ${record.highScore}`,
|
|
4354
|
+
` pro high score ${record.proHighScore}`,
|
|
4355
|
+
` last score ${record.lastScore}`,
|
|
4356
|
+
` best tokens ${record.bestTokens}`,
|
|
4357
|
+
` total tokens ${record.totalTokens}`,
|
|
4358
|
+
` games played ${record.gamesPlayed}`,
|
|
4359
|
+
` last game ${formatDate(record.updatedAt)}`
|
|
4360
|
+
].join("\n");
|
|
4361
|
+
}
|
|
4362
|
+
var SCORES_COMMAND = {
|
|
4363
|
+
name: "scores",
|
|
4364
|
+
aliases: ["score", "hi", "highscore"],
|
|
4365
|
+
usage: "dario scores [--json]",
|
|
4366
|
+
description: "Show high score, token totals and games played.",
|
|
4367
|
+
async execute(args) {
|
|
4368
|
+
const record = new ScoreStore({ path: DARIO_PATHS.scoresFile }).load();
|
|
4369
|
+
console.log(args.includes("--json") ? JSON.stringify(record) : renderScores(record));
|
|
4370
|
+
return 0;
|
|
4371
|
+
}
|
|
4372
|
+
};
|
|
4373
|
+
|
|
4374
|
+
// src/cli/commands/sponsors_command.ts
|
|
4375
|
+
var NAME_WIDTH3 = 20;
|
|
4376
|
+
function renderSponsor(sponsor) {
|
|
4377
|
+
return ` ${sponsor.name.padEnd(NAME_WIDTH3)} ${sponsor.tier.padEnd(9)} ${sponsor.url} until ${new Date(sponsor.expiresAt).toLocaleDateString()}`;
|
|
4378
|
+
}
|
|
4379
|
+
var SPONSORS_COMMAND = {
|
|
4380
|
+
name: "sponsors",
|
|
4381
|
+
aliases: ["sponsor"],
|
|
4382
|
+
usage: "dario sponsors [--json]",
|
|
4383
|
+
description: "List the active sponsors whose logos appear in the game.",
|
|
4384
|
+
async execute(args) {
|
|
4385
|
+
const sponsors = await createSponsorService().loadSponsors();
|
|
4386
|
+
if (args.includes("--json")) {
|
|
4387
|
+
console.log(JSON.stringify(sponsors));
|
|
4388
|
+
return 0;
|
|
4389
|
+
}
|
|
4390
|
+
const lines = sponsors.length === 0 ? [" no active sponsors yet"] : sponsors.map(renderSponsor);
|
|
4391
|
+
console.log(["Dario sponsors", ...lines, "", `Become one: ${FIREBASE_CONFIG.siteUrl}/sponsor`].join("\n"));
|
|
4392
|
+
return 0;
|
|
4393
|
+
}
|
|
4394
|
+
};
|
|
4395
|
+
|
|
4396
|
+
// src/cli/commands/status_command.ts
|
|
4397
|
+
var STATUS_COMMAND = {
|
|
4398
|
+
name: "status",
|
|
4399
|
+
aliases: ["info"],
|
|
4400
|
+
usage: "dario status",
|
|
4401
|
+
description: "Show whether Dario is running, hook installation, auto-launch and Claude state.",
|
|
4402
|
+
async execute() {
|
|
4403
|
+
const lock = new ProcessLock({ path: DARIO_PATHS.pidFile });
|
|
4404
|
+
const config = new ConfigStore({ path: DARIO_PATHS.configFile }).load();
|
|
4405
|
+
const claude = new ClaudeStatusWriter({ path: DARIO_PATHS.claudeStatusFile }).read();
|
|
4406
|
+
const scores = new ScoreStore({ path: DARIO_PATHS.scoresFile }).load();
|
|
4407
|
+
const agents = new AgentHooksInstaller();
|
|
4408
|
+
const lines = [
|
|
4409
|
+
"Dario status",
|
|
4410
|
+
` running ${lock.isHeld() ? `yes (pid ${lock.readPid()})` : "no"}`,
|
|
4411
|
+
` claude hooks ${new DarioInstaller().isInstalled() ? "installed" : 'not installed (run "dario install")'}`,
|
|
4412
|
+
...describeAgentReports(agents.listKinds().map((kind) => agents.describe(kind))).map((line) => line.replace(/^ {2}(\S+ hooks)\s*/, (_match, label) => ` ${label.padEnd(16)}`)),
|
|
4413
|
+
` player ${config.playerName.length === 0 ? "(asked on first play)" : config.playerName}`,
|
|
4414
|
+
` character ${config.character}`,
|
|
4415
|
+
` mode ${GAME_MODE_LABELS[config.mode]}`,
|
|
4416
|
+
` auto-launch ${config.autoLaunch ? "on" : "off"}`,
|
|
4417
|
+
` return-focus ${config.returnFocus ? "on" : "off"}`,
|
|
4418
|
+
` terminal ${config.terminal}`,
|
|
4419
|
+
` agent state ${claude === null ? "unknown" : `${claude.agent} ${claude.status} (${new Date(claude.updatedAt).toLocaleTimeString()})`}`,
|
|
4420
|
+
` high score ${scores.highScore}`,
|
|
4421
|
+
` project ${DARIO_PATHS.rootDir}`
|
|
4422
|
+
];
|
|
4423
|
+
console.log(lines.join("\n"));
|
|
4424
|
+
return 0;
|
|
4425
|
+
}
|
|
4426
|
+
};
|
|
4427
|
+
|
|
4428
|
+
// src/cli/commands/stop_command.ts
|
|
4429
|
+
var STOP_COMMAND = {
|
|
4430
|
+
name: "stop",
|
|
4431
|
+
aliases: ["kill"],
|
|
4432
|
+
usage: "dario stop",
|
|
4433
|
+
description: "Close the running Dario window.",
|
|
4434
|
+
async execute() {
|
|
4435
|
+
const lock = new ProcessLock({ path: DARIO_PATHS.pidFile });
|
|
4436
|
+
const pid = lock.readPid();
|
|
4437
|
+
if (pid === null || !lock.isHeld()) {
|
|
4438
|
+
console.log("Dario is not running.");
|
|
4439
|
+
return 0;
|
|
4440
|
+
}
|
|
4441
|
+
process.kill(pid, "SIGTERM");
|
|
4442
|
+
console.log(`Stopped Dario (pid ${pid}).`);
|
|
4443
|
+
return 0;
|
|
4444
|
+
}
|
|
4445
|
+
};
|
|
4446
|
+
|
|
4447
|
+
// src/cli/commands/terminal_command.ts
|
|
4448
|
+
var TERMINAL_KINDS = ["auto", ...INSTALL_PREFERENCE, "apple_terminal", ...IDE_KINDS];
|
|
4449
|
+
var KIND_WIDTH2 = 17;
|
|
4450
|
+
var LIST_FLAG = "--list";
|
|
4451
|
+
function isTerminalKind(value) {
|
|
4452
|
+
return TERMINAL_KINDS.includes(value);
|
|
4453
|
+
}
|
|
4454
|
+
var TERMINAL_COMMAND = {
|
|
4455
|
+
name: "terminal",
|
|
4456
|
+
aliases: ["term"],
|
|
4457
|
+
usage: "dario terminal <auto|name> | --list",
|
|
4458
|
+
description: `Choose which terminal app or IDE "dario launch" and the hooks open (${TERMINAL_KINDS.join(", ")}).`,
|
|
4459
|
+
async execute(args) {
|
|
4460
|
+
const store = new ConfigStore({ path: DARIO_PATHS.configFile });
|
|
4461
|
+
const raw = (args[0] ?? "").toLowerCase();
|
|
4462
|
+
if (raw === LIST_FLAG) {
|
|
4463
|
+
listLaunchTargets().forEach((target) => console.log(` ${target.kind.padEnd(KIND_WIDTH2)} ${target.label}${target.isDetected ? " (detected)" : ""}`));
|
|
4464
|
+
return 0;
|
|
4465
|
+
}
|
|
4466
|
+
const value = raw.replaceAll("-", "_");
|
|
4467
|
+
if (value.length === 0) {
|
|
4468
|
+
console.log(`Terminal preference: ${store.load().terminal}`);
|
|
4469
|
+
return 0;
|
|
4470
|
+
}
|
|
4471
|
+
if (!isTerminalKind(value)) {
|
|
4472
|
+
console.error(`Unknown terminal "${value}". Options: ${TERMINAL_KINDS.join(", ")}`);
|
|
4473
|
+
return 1;
|
|
4474
|
+
}
|
|
4475
|
+
console.log(`Terminal preference is now ${store.update({ terminal: value }).terminal}.`);
|
|
4476
|
+
return 0;
|
|
4477
|
+
}
|
|
4478
|
+
};
|
|
4479
|
+
|
|
4480
|
+
// src/cli/commands/uninstall_command.ts
|
|
4481
|
+
var UNINSTALL_COMMAND = {
|
|
4482
|
+
name: "uninstall",
|
|
4483
|
+
aliases: ["remove"],
|
|
4484
|
+
usage: "dario uninstall",
|
|
4485
|
+
description: "Remove the Claude Code / Codex / Grok hooks, the skill link and the dario command.",
|
|
4486
|
+
async execute() {
|
|
4487
|
+
const report = new DarioInstaller().uninstall();
|
|
4488
|
+
const lines = [
|
|
4489
|
+
"Dario uninstalled",
|
|
4490
|
+
` claude hooks ${report.hooks}`,
|
|
4491
|
+
...describeAgentReports(report.agents),
|
|
4492
|
+
` skill link ${report.skill}`,
|
|
4493
|
+
` dario command ${report.bin}`
|
|
4494
|
+
];
|
|
4495
|
+
console.log(lines.join("\n"));
|
|
4496
|
+
return 0;
|
|
4497
|
+
}
|
|
4498
|
+
};
|
|
4499
|
+
|
|
4500
|
+
// src/cli/commands/update_command.ts
|
|
4501
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
4502
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
4503
|
+
import { join as join6 } from "node:path";
|
|
4504
|
+
var TIMEOUT_MS = 8e3;
|
|
4505
|
+
function readLocalVersion() {
|
|
4506
|
+
return JSON.parse(readFileSync4(join6(DARIO_PATHS.rootDir, "package.json"), "utf8")).version;
|
|
4507
|
+
}
|
|
4508
|
+
async function fetchRelease() {
|
|
4509
|
+
const response = await fetch(`${FIREBASE_CONFIG.siteUrl}/dl/version.json`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
4510
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4511
|
+
return await response.json();
|
|
4512
|
+
}
|
|
4513
|
+
function runInstaller() {
|
|
4514
|
+
const result = spawnSync6("sh", ["-c", `curl -fsSL ${FIREBASE_CONFIG.siteUrl}/install.sh | sh`], { stdio: "inherit" });
|
|
4515
|
+
return result.status ?? 1;
|
|
4516
|
+
}
|
|
4517
|
+
var UPDATE_COMMAND = {
|
|
4518
|
+
name: "update",
|
|
4519
|
+
aliases: ["upgrade"],
|
|
4520
|
+
usage: "dario update [--check]",
|
|
4521
|
+
description: "Check the website for a newer release and install it.",
|
|
4522
|
+
async execute(args) {
|
|
4523
|
+
const local = readLocalVersion();
|
|
4524
|
+
let release;
|
|
4525
|
+
try {
|
|
4526
|
+
release = await fetchRelease();
|
|
4527
|
+
} catch (err) {
|
|
4528
|
+
console.error(`Could not check for updates: ${err instanceof Error ? err.message : String(err)}`);
|
|
4529
|
+
return 1;
|
|
4530
|
+
}
|
|
4531
|
+
console.log(`installed ${local}, latest ${release.version} (${release.publishedAt})`);
|
|
4532
|
+
if (release.version === local || args.includes("--check")) return 0;
|
|
4533
|
+
if (DARIO_PATHS.rootDir.includes("/node_modules/")) {
|
|
4534
|
+
console.log('This copy was installed with npm: run "npm install -g superdario@latest" to update.');
|
|
4535
|
+
return 0;
|
|
4536
|
+
}
|
|
4537
|
+
if (!DARIO_PATHS.rootDir.includes("/.dario/")) {
|
|
4538
|
+
console.log("This copy is a source checkout, not the website install; update it with git instead.");
|
|
4539
|
+
return 0;
|
|
4540
|
+
}
|
|
4541
|
+
return runInstaller();
|
|
4542
|
+
}
|
|
4543
|
+
};
|
|
4544
|
+
|
|
4545
|
+
// src/cli/command_registry.ts
|
|
4546
|
+
var HELP_COMMAND = createHelpCommand({ commands: () => COMMANDS });
|
|
4547
|
+
var COMMANDS = [
|
|
4548
|
+
PLAY_COMMAND,
|
|
4549
|
+
LAUNCH_COMMAND,
|
|
4550
|
+
STOP_COMMAND,
|
|
4551
|
+
SCORES_COMMAND,
|
|
4552
|
+
RANKING_COMMAND,
|
|
4553
|
+
SPONSORS_COMMAND,
|
|
4554
|
+
NAME_COMMAND,
|
|
4555
|
+
CHARACTER_COMMAND,
|
|
4556
|
+
MODE_COMMAND,
|
|
4557
|
+
STATUS_COMMAND,
|
|
4558
|
+
AUTO_LAUNCH_COMMAND,
|
|
4559
|
+
FOCUS_COMMAND,
|
|
4560
|
+
TERMINAL_COMMAND,
|
|
4561
|
+
IDE_COMMAND,
|
|
4562
|
+
AGENTS_COMMAND,
|
|
4563
|
+
INSTALL_COMMAND,
|
|
4564
|
+
UNINSTALL_COMMAND,
|
|
4565
|
+
UPDATE_COMMAND,
|
|
4566
|
+
HOOK_COMMAND,
|
|
4567
|
+
KEYS_COMMAND,
|
|
4568
|
+
HELP_COMMAND
|
|
4569
|
+
];
|
|
4570
|
+
|
|
4571
|
+
// src/cli/run_cli.ts
|
|
4572
|
+
var DEFAULT_COMMAND = "play";
|
|
4573
|
+
function findCommand(name) {
|
|
4574
|
+
return COMMANDS.find((command) => command.name === name || command.aliases.includes(name));
|
|
4575
|
+
}
|
|
4576
|
+
async function runCli(argv) {
|
|
4577
|
+
const [name = DEFAULT_COMMAND, ...rest] = argv;
|
|
4578
|
+
const command = findCommand(name);
|
|
4579
|
+
if (command === void 0) {
|
|
4580
|
+
console.error(`Unknown command "${name}". Try "dario help".`);
|
|
4581
|
+
return 1;
|
|
4582
|
+
}
|
|
4583
|
+
const isAliasArgument = command.name !== name && command.aliases.includes(name) && command.name === "auto";
|
|
4584
|
+
return command.execute(isAliasArgument ? [name, ...rest] : rest);
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4587
|
+
// src/main.ts
|
|
4588
|
+
runCli(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
|
|
4589
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
4590
|
+
process.exit(1);
|
|
4591
|
+
});
|