nou-cli 0.5.1 → 0.5.2

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.
@@ -1,7 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs/promises";
3
3
  import { readdirSync } from "fs";
4
- import { exec } from "child_process";
4
+ import { spawn } from "child_process";
5
5
  export const IGNORED = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
6
6
  /** Whether a path is inside the project folder (where Nou may read without asking and write at all). */
7
7
  export function isInProject(filePath) {
@@ -42,20 +42,35 @@ export async function readForModel(filePath, offset = 1, limit = READ_LINES) {
42
42
  const lines = text.split(/\r?\n/);
43
43
  if (lines.length > 1 && lines.at(-1) === "")
44
44
  lines.pop();
45
+ if (text === "")
46
+ return "(The file is empty.)";
45
47
  const start = Math.max(1, Math.floor(offset) || 1);
46
48
  if (start > lines.length)
47
49
  return `(The file has ${lines.length} lines.)`;
48
50
  let end = Math.min(lines.length, start + Math.max(1, Math.floor(limit) || READ_LINES) - 1);
49
- if (start === 1 && end === lines.length && text.length <= READ_CHARS)
50
- return text;
51
- let body = lines.slice(start - 1, end).join("\n");
52
- if (body.length > READ_CHARS) {
53
- body = body.slice(0, READ_CHARS);
54
- end = start + body.split("\n").length - 1;
55
- body = `${body}\n[... line ${end} cut off: it is very long]`;
51
+ // Numbered like cat -n, so the model can cite and target exact lines (edit_file ignores the numbers).
52
+ const width = String(end).length;
53
+ let body = "";
54
+ for (let n = start; n <= end; n++) {
55
+ const line = `${String(n).padStart(width)}\t${lines[n - 1]}\n`;
56
+ if (body.length + line.length > READ_CHARS) {
57
+ if (n === start) {
58
+ // A single huge line (minified code, one-line JSON): show its start rather than nothing.
59
+ body += `${line.slice(0, READ_CHARS)}\n[... line ${n} cut off: it is ${lines[n - 1].length} characters long]\n`;
60
+ end = n;
61
+ }
62
+ else {
63
+ end = n - 1;
64
+ body += `[... stopped at line ${end} to keep this short]\n`;
65
+ }
66
+ break;
67
+ }
68
+ body += line;
56
69
  }
70
+ if (start === 1 && end === lines.length)
71
+ return body.trimEnd();
57
72
  const more = end < lines.length ? ` Read more with offset=${end + 1}.` : "";
58
- return `${body}\n\n[Lines ${start}-${end} of ${lines.length}.${more}]`;
73
+ return `${body}\n[Lines ${start}-${end} of ${lines.length}.${more}]`;
59
74
  }
60
75
  /** Keeps tool output a sensible size for the model: the start and, where errors usually are, the end. */
61
76
  export function capOutput(text, max = 40_000) {
@@ -65,19 +80,104 @@ export function capOutput(text, max = 40_000) {
65
80
  const tail = max - head;
66
81
  return `${text.slice(0, head)}\n\n[... ${text.length - max} characters cut ...]\n\n${text.slice(-tail)}`;
67
82
  }
68
- export function runCommand(command) {
69
- // Async so the UI keeps rendering while the command runs.
83
+ /** How long run_command waits by default, and at most. */
84
+ export const COMMAND_TIMEOUT = 120_000;
85
+ export const MAX_COMMAND_TIMEOUT = 600_000;
86
+ /** Output kept from a command: the start and the end (where errors and summaries are) when it's longer. */
87
+ const OUTPUT_KEEP = 200_000;
88
+ /**
89
+ * Runs a shell command and returns its output (stdout and stderr together, in the order written) and,
90
+ * when it failed, its exit code. Async so the UI keeps rendering while it runs.
91
+ */
92
+ /** Commands still running, so they can be stopped when Nou exits instead of being left behind. */
93
+ const running = new Set();
94
+ let exitHooked = false;
95
+ /** Stops a command and everything it started. */
96
+ function stopProcess(child) {
97
+ try {
98
+ if (process.platform === "win32") {
99
+ if (child.pid)
100
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
101
+ else
102
+ child.kill("SIGKILL");
103
+ }
104
+ else if (child.pid)
105
+ process.kill(-child.pid, "SIGKILL");
106
+ }
107
+ catch {
108
+ child.kill("SIGKILL");
109
+ }
110
+ }
111
+ /**
112
+ * Runs a shell command and returns its output (stdout and stderr together, in the order written) and,
113
+ * when it failed, its exit code. Async so the UI keeps rendering while it runs. `signal` (Esc) stops it.
114
+ */
115
+ export function runCommand(command, timeout = COMMAND_TIMEOUT, signal) {
70
116
  return new Promise((resolve) => {
71
- exec(command, { cwd: process.cwd(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
72
- if (error) {
73
- const reason = error.killed ? "timed out after 2 minutes" : `Exit Code ${error.code ?? "?"}`;
74
- resolve(`Error (${reason}): ${stderr || error.message}${stdout ? `
75
- ${stdout}` : ""}`);
117
+ const posix = process.platform !== "win32";
118
+ // Its own process group on macOS and Linux, so stopping it stops everything it started.
119
+ const child = spawn(command, { cwd: process.cwd(), shell: true, stdio: ["ignore", "pipe", "pipe"], detached: posix });
120
+ running.add(child);
121
+ if (!exitHooked) {
122
+ exitHooked = true;
123
+ process.on("exit", () => running.forEach(stopProcess));
124
+ // Closing the terminal ends Nou with SIGHUP, which skips "exit": stop the commands then too.
125
+ if (process.platform !== "win32") {
126
+ process.once("SIGHUP", () => {
127
+ running.forEach(stopProcess);
128
+ process.exit(129);
129
+ });
76
130
  }
77
- else {
78
- resolve(stdout);
131
+ }
132
+ let head = "";
133
+ let tail = "";
134
+ let dropped = 0;
135
+ const take = (text) => {
136
+ if (head.length < OUTPUT_KEEP / 2) {
137
+ head += text;
138
+ return;
79
139
  }
80
- });
140
+ tail += text;
141
+ if (tail.length > OUTPUT_KEEP / 2) {
142
+ dropped += tail.length - OUTPUT_KEEP / 2;
143
+ tail = tail.slice(-OUTPUT_KEEP / 2);
144
+ }
145
+ };
146
+ // Decoded as a stream, so a character split across two chunks isn't garbled.
147
+ child.stdout.setEncoding("utf8").on("data", take);
148
+ child.stderr.setEncoding("utf8").on("data", take);
149
+ const limit = Math.min(Math.max(1000, timeout), MAX_COMMAND_TIMEOUT);
150
+ let stopped = null;
151
+ const timer = setTimeout(() => {
152
+ stopped = "timeout";
153
+ stopProcess(child);
154
+ }, limit);
155
+ const onAbort = () => {
156
+ stopped = "cancelled";
157
+ stopProcess(child);
158
+ };
159
+ signal?.addEventListener("abort", onAbort, { once: true });
160
+ let done = false;
161
+ const finish = (code, error) => {
162
+ if (done)
163
+ return;
164
+ done = true;
165
+ clearTimeout(timer);
166
+ signal?.removeEventListener("abort", onAbort);
167
+ running.delete(child);
168
+ const output = (dropped ? `${head}\n[... ${dropped} characters cut ...]\n${tail}` : head + tail).trimEnd();
169
+ if (error)
170
+ return resolve(`Error: ${error.message}`);
171
+ if (stopped === "cancelled")
172
+ return resolve(`Stopped: the user cancelled it.\n${output}`);
173
+ if (stopped === "timeout")
174
+ return resolve(`Error (timed out after ${Math.round(limit / 1000)}s; pass a longer timeout if it needs one):\n${output}`);
175
+ resolve(code === 0 ? output || "(no output)" : `Error (exit code ${code ?? "?"}):\n${output || "(no output)"}`);
176
+ };
177
+ child.on("error", (error) => finish(null, error));
178
+ child.on("close", (code) => finish(code));
179
+ if (signal?.aborted)
180
+ onAbort();
81
181
  });
82
182
  }
83
183
  export function fileTree(dir = ".", maxDepth = 3, maxLines = Infinity) {
@@ -141,29 +241,137 @@ export async function proposeWrite(filePath, content) {
141
241
  const before = await readOrEmpty(fullPath);
142
242
  return { path: toRelative(filePath), before: before ?? "", after: cleanContent(content), isNew: before === null };
143
243
  }
244
+ /** Typographic characters models sometimes put in place of the plain ones in the file. */
245
+ const PLAIN = { "\u2018": "'", "\u2019": "'", "\u201c": "\"", "\u201d": "\"", "\u2013": "-", "\u2014": "-", "\u00a0": " ", "\u2026": "..." };
246
+ const plainChars = (s) => s.replace(/[\u2018\u2019\u201c\u201d\u2013\u2014\u00a0\u2026]/g, (c) => PLAIN[c]);
247
+ /**
248
+ * Where `target` is in `text`, matching whole lines loosely: ignoring trailing spaces, then indentation,
249
+ * then typographic quotes and dashes. Only a unique match counts: a wrong edit is worse than none.
250
+ */
251
+ function looseMatch(text, target) {
252
+ const lines = text.split("\n");
253
+ const want = target.replace(/\n$/, "").split("\n");
254
+ const offsets = [];
255
+ for (let i = 0, at = 0; i < lines.length; i++) {
256
+ offsets.push(at);
257
+ at += lines[i].length + 1;
258
+ }
259
+ for (const norm of [(l) => l.trimEnd(), (l) => l.trim(), (l) => plainChars(l).trim()]) {
260
+ const wanted = want.map(norm);
261
+ const hits = [];
262
+ for (let i = 0; i + wanted.length <= lines.length && hits.length < 2; i++) {
263
+ if (wanted.every((w, k) => norm(lines[i + k]) === w))
264
+ hits.push(i);
265
+ }
266
+ if (hits.length === 1) {
267
+ const first = hits[0];
268
+ const last = first + wanted.length - 1;
269
+ // How the file indents the match versus how old_string did, so new_string can be shifted to match.
270
+ const k = Math.max(0, want.findIndex((l) => l.trim()));
271
+ const lead = (l) => l.match(/^\s*/)[0];
272
+ return { start: offsets[first], end: offsets[last] + lines[last].length, fileIndent: lead(lines[first + k]), targetIndent: lead(want[k]) };
273
+ }
274
+ if (hits.length > 1)
275
+ return null;
276
+ }
277
+ return null;
278
+ }
279
+ /** The lines of `text` most like the start of `target`, numbered: a hint for the model's next try. */
280
+ function nearest(text, target) {
281
+ const lines = text.split("\n");
282
+ const first = target.split("\n").find((l) => l.trim())?.trim() ?? "";
283
+ if (!first)
284
+ return "";
285
+ const words = new Set(first.split(/\W+/).filter((w) => w.length > 2));
286
+ let best = -1;
287
+ let bestScore = 0;
288
+ lines.forEach((line, i) => {
289
+ const score = line.split(/\W+/).filter((w) => words.has(w)).length;
290
+ if (score > bestScore) {
291
+ best = i;
292
+ bestScore = score;
293
+ }
294
+ });
295
+ if (best < 0)
296
+ return "";
297
+ const from = Math.max(0, best - 2);
298
+ return lines.slice(from, best + 4).map((l, k) => `${String(from + k + 1).padStart(5)}\t${l}`).join("\n");
299
+ }
300
+ /** Line numbers copied from read_file's output (" 12\tcode"): dropped when old_string doesn't match with them. */
301
+ const stripLineNumbers = (s) => (s.split("\n").every((l) => !l || /^\s*\d+\t/.test(l)) ? s.replace(/^\s*\d+\t/gm, "") : s);
144
302
  /** Works out what edit_file would do, without touching the disk. */
145
303
  export async function proposeEdit(filePath, oldString, newString, replaceAll = false) {
146
304
  const fullPath = resolveInProject(filePath);
147
305
  const before = await readOrEmpty(fullPath);
148
306
  if (before === null)
149
307
  throw new Error(`${filePath} does not exist. Use write_file to create it.`);
308
+ const after = editText(before, oldString, newString, replaceAll);
309
+ return { path: toRelative(filePath), before, after, isNew: false };
310
+ }
311
+ /** Applies one edit to a file's text (see proposeEdit), throwing an error the model can act on. */
312
+ export function editText(before, oldString, newString, replaceAll = false) {
150
313
  // Models often send LF while Windows files use CRLF, so match on normalized text.
151
314
  const eol = before.includes("\r\n") ? "\r\n" : "\n";
152
315
  const normalized = before.replace(/\r\n/g, "\n");
153
- const target = oldString.replace(/\r\n/g, "\n");
154
- const count = target ? normalized.split(target).length - 1 : 0;
155
- if (count === 0)
156
- throw new Error("old_string was not found in the file. Read the file and copy the text exactly.");
316
+ let target = oldString.replace(/\r\n/g, "\n");
317
+ let replacement = newString.replace(/\r\n/g, "\n");
318
+ if (!target)
319
+ throw new Error("old_string is empty. To create a file use write_file; to add to one, include the line you're adding after.");
320
+ let count = normalized.split(target).length - 1;
321
+ if (count === 0 && stripLineNumbers(target) !== target) {
322
+ target = stripLineNumbers(target);
323
+ replacement = stripLineNumbers(replacement);
324
+ count = normalized.split(target).length - 1;
325
+ }
326
+ if (count === 0) {
327
+ const loose = looseMatch(normalized, target);
328
+ if (loose) {
329
+ let text = replacement.replace(/\n$/, "");
330
+ // old_string was indented differently from the file: shift new_string the same way, so the code stays in its block.
331
+ // (Only when new_string is indented like old_string; if it already has the file's indentation, it stays.)
332
+ const newIndent = text.split("\n").find((l) => l.trim())?.match(/^\s*/)[0] ?? "";
333
+ if (loose.fileIndent !== loose.targetIndent && newIndent === loose.targetIndent) {
334
+ const { fileIndent: to, targetIndent: from } = loose;
335
+ text = text.split("\n").map((l) => (!l.trim() ? l : l.startsWith(from) ? to + l.slice(from.length) : l)).join("\n");
336
+ }
337
+ // Deleting whole lines takes their line break too, rather than leaving a blank line.
338
+ const end = !text && normalized[loose.end] === "\n" ? loose.end + 1 : loose.end;
339
+ return (normalized.slice(0, loose.start) + text + normalized.slice(end)).replace(/\n/g, eol);
340
+ }
341
+ const near = nearest(normalized, target);
342
+ throw new Error(`old_string was not found in the file. Copy it exactly from read_file (without the line numbers).${near ? ` The closest lines are:\n${near}` : " Read the file again first."}`);
343
+ }
157
344
  if (count > 1 && !replaceAll) {
158
345
  throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique, or set replace_all to change every one.`);
159
346
  }
160
- const replacement = newString.replace(/\r\n/g, "\n");
161
- const after = (replaceAll ? normalized.split(target).join(replacement) : normalized.replace(target, () => replacement)).replace(/\n/g, eol);
347
+ return (replaceAll ? normalized.split(target).join(replacement) : normalized.replace(target, () => replacement)).replace(/\n/g, eol);
348
+ }
349
+ /** edit_file with several edits: each applies to the result of the one before, and all must work. */
350
+ export async function proposeEdits(filePath, edits) {
351
+ const fullPath = resolveInProject(filePath);
352
+ const before = await readOrEmpty(fullPath);
353
+ if (before === null)
354
+ throw new Error(`${filePath} does not exist. Use write_file to create it.`);
355
+ let after = before;
356
+ edits.forEach((e, i) => {
357
+ try {
358
+ after = editText(after, String(e.old_string ?? ""), String(e.new_string ?? ""), e.replace_all === true);
359
+ }
360
+ catch (error) {
361
+ throw new Error(`Edit ${i + 1} of ${edits.length}: ${error.message} (none of the edits were made)`, { cause: error });
362
+ }
363
+ });
162
364
  return { path: toRelative(filePath), before, after, isNew: false };
163
365
  }
164
366
  export async function applyChange(change) {
165
367
  try {
166
368
  const fullPath = resolveInProject(change.path);
369
+ // The edit was worked out from the file as it was then; if it has changed since (you edited it while
370
+ // the edit waited for approval), writing it would throw your changes away.
371
+ const now = await readOrEmpty(fullPath);
372
+ if (change.isNew ? now !== null : now !== change.before) {
373
+ return `Failed: ${change.path} changed after the edit was worked out (it was edited meanwhile), so it wasn't written. Read it again and redo the edit.`;
374
+ }
167
375
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
168
376
  await fs.writeFile(fullPath, change.after, "utf-8");
169
377
  return `Successfully ${change.isNew ? "created" : "updated"} ${change.path}`;
@@ -5,18 +5,27 @@ export const NOU_HOME = path.join(os.homedir(), ".nou");
5
5
  const CONFIG_PATH = path.join(NOU_HOME, "config.json");
6
6
  export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
7
7
  export const AUTO_SAVE = ["delay", "focus", "off"];
8
+ /** How fast follow mode replays what the agent does: a step every so often, or as it happens. */
9
+ export const FOLLOW_SPEEDS = ["slow", "normal", "fast", "instant"];
10
+ /** Color themes for code (see src/syntax/themes.ts). */
11
+ export const CODE_THEMES = ["one-dark", "tokyo-night", "github-dark", "dracula", "high-contrast", "github-light"];
8
12
  export const DEFAULT_SETTINGS = {
9
13
  model: "",
10
14
  engine: "nou",
11
15
  effort: "high",
12
16
  followMode: false,
17
+ followSpeed: "normal",
13
18
  autoApproveEdits: false,
14
19
  autoApproveCommands: false,
15
20
  gitContext: true,
16
21
  autoSave: "delay",
22
+ codeTheme: "one-dark",
17
23
  mouse: true,
18
24
  reuseToolConfig: true,
19
25
  paneWidths: null,
26
+ trustedFolders: [],
27
+ games: [],
28
+ gamePanel: null,
20
29
  plugins: {},
21
30
  trustedPlugins: {},
22
31
  };
@@ -41,3 +50,8 @@ export function saveSettings(patch) {
41
50
  }
42
51
  return next;
43
52
  }
53
+ /** Whether you trust this folder (or one it's inside). */
54
+ export function isTrustedFolder(dir = process.cwd(), settings = loadSettings()) {
55
+ const here = path.resolve(dir);
56
+ return settings.trustedFolders.some((t) => here === t || here.startsWith(t.endsWith(path.sep) ? t : t + path.sep));
57
+ }
@@ -1,12 +1,9 @@
1
- import { highlight } from "cli-highlight";
2
1
  import fs from "fs/promises";
3
2
  import path from "path";
4
3
  import { fileTree, readFile } from "../commands/index.js";
5
4
  import { complete } from "../providers/index.js";
6
5
  import * as p from "@clack/prompts";
7
- export const sugarfy = (str) => {
8
- return highlight(str, { language: "typescript", ignoreIllegals: true });
9
- };
6
+ import { NOU_HOME } from "../config/index.js";
10
7
  export async function generateContext(contextPath, choice) {
11
8
  const s = p.spinner();
12
9
  s.start("Analyzing project structure to create NOU.md...");
@@ -41,13 +38,39 @@ export async function ensureProjectContext(choice) {
41
38
  await generateContext(contextPath, choice);
42
39
  }
43
40
  }
41
+ /** Instruction files other coding agents use too, so a repo's existing rules apply to Nou as well. */
42
+ const INSTRUCTION_FILES = ["NOU.md", "AGENTS.md", "CLAUDE.md"];
43
+ const MAX_INSTRUCTIONS = 20_000;
44
+ /**
45
+ * The project's standing instructions: NOU.md, AGENTS.md and CLAUDE.md here and in the folders above it
46
+ * up to the repository root (outermost first, so nearer files have the last word), plus ~/.nou/NOU.md.
47
+ */
44
48
  export async function loadPermanentContext() {
45
- const contextPath = path.join(process.cwd(), "NOU.md");
46
- try {
47
- const content = await fs.readFile(contextPath, "utf-8");
48
- return `\n\nPROJECT SPECIFIC RULES (from NOU.md):\n${content}`;
49
+ const dirs = [];
50
+ for (let dir = process.cwd();; dir = path.dirname(dir)) {
51
+ dirs.unshift(dir);
52
+ const atRoot = await fs.access(path.join(dir, ".git")).then(() => true, () => false);
53
+ if (atRoot || path.dirname(dir) === dir)
54
+ break;
49
55
  }
50
- catch {
51
- return "";
56
+ const files = [path.join(NOU_HOME, "NOU.md"), ...dirs.flatMap((dir) => INSTRUCTION_FILES.map((name) => path.join(dir, name)))];
57
+ const seen = new Set();
58
+ const parts = [];
59
+ for (const file of files) {
60
+ const content = (await fs.readFile(file, "utf-8").catch(() => "")).trim();
61
+ if (!content || seen.has(content))
62
+ continue;
63
+ seen.add(content);
64
+ const where = file.startsWith(NOU_HOME) ? "~/.nou/NOU.md" : path.relative(process.cwd(), file) || path.basename(file);
65
+ parts.push(`From ${where}:\n${content.length > MAX_INSTRUCTIONS ? `${content.slice(0, MAX_INSTRUCTIONS)}\n[... cut]` : content}`);
52
66
  }
67
+ return parts.length ? `\n\nPROJECT SPECIFIC RULES:\n${parts.join("\n\n")}` : "";
68
+ }
69
+ /**
70
+ * Just the NOU.md parts of the project rules, for engines that read AGENTS.md / CLAUDE.md themselves
71
+ * (Claude Code, Codex): giving them those again would only repeat them.
72
+ */
73
+ export function nouRulesOnly(rules) {
74
+ const parts = rules.replace(/^\s*PROJECT SPECIFIC RULES:\n/, "").split(/\n\n(?=From )/).filter((p) => /^From (\S*[\\/])?NOU\.md:|^From ~\/\.nou\/NOU\.md:/.test(p.trim()));
75
+ return parts.length ? `\n\nPROJECT SPECIFIC RULES:\n${parts.join("\n\n")}` : "";
53
76
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Little games for the Games panel, drawn with text. Each one is plain state and three functions, so the
3
+ * panel can run any of them and the tests can play them without a terminal.
4
+ */
5
+ /** A small seeded random number generator, so games can be replayed in tests. */
6
+ function rng(seed) {
7
+ let s = seed >>> 0 || 1;
8
+ return () => {
9
+ s ^= s << 13;
10
+ s ^= s >>> 17;
11
+ s ^= s << 5;
12
+ return (s >>> 0) / 4294967296;
13
+ };
14
+ }
15
+ const blank = (w, h) => Array.from({ length: h }, () => Array(w).fill(" "));
16
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
17
+ const PADDLE = 3;
18
+ const PONG_WIN = 7;
19
+ function serve(s, toward) {
20
+ const vy = (s.random() - 0.5) * 1.2;
21
+ return { ...s, bx: s.w / 2, by: s.h / 2, vx: toward * 0.9, vy: Math.abs(vy) < 0.2 ? 0.4 : vy };
22
+ }
23
+ export const pong = {
24
+ id: "pong",
25
+ title: "Pong",
26
+ blurb: "The classic: you on the left, the computer on the right. First to 7.",
27
+ controls: "↑↓ or w/s move",
28
+ tickMs: 50,
29
+ colors: { "█": "#61afef", "●": "#e5c07b", "┊": "#5c6370" },
30
+ create(w, h, seed = Date.now()) {
31
+ const s = { w, h, you: Math.floor((h - PADDLE) / 2), them: Math.floor((h - PADDLE) / 2), bx: 0, by: 0, vx: 0, vy: 0, scores: [0, 0], random: rng(seed) };
32
+ return serve(s, -1);
33
+ },
34
+ tick(s, inputs) {
35
+ if (s.scores[0] >= PONG_WIN || s.scores[1] >= PONG_WIN)
36
+ return s;
37
+ let you = s.you;
38
+ for (const i of inputs)
39
+ you += i === "up" ? -1 : i === "down" ? 1 : 0;
40
+ you = clamp(you, 0, s.h - PADDLE);
41
+ // The computer follows the ball, a bit slower than it moves, so it can be beaten.
42
+ const target = s.by - PADDLE / 2;
43
+ const them = clamp(s.them + clamp(target - s.them, -0.55, 0.55), 0, s.h - PADDLE);
44
+ let { bx, by, vx, vy } = s;
45
+ bx += vx;
46
+ by += vy;
47
+ if (by < 0)
48
+ [by, vy] = [-by, -vy];
49
+ if (by > s.h - 1)
50
+ [by, vy] = [2 * (s.h - 1) - by, -vy];
51
+ // Paddles at x=1 and x=w-2; where the ball hits the paddle sets its angle.
52
+ if (vx < 0 && bx <= 1.5 && bx >= 0.5 && by >= you - 0.5 && by <= you + PADDLE - 0.5) {
53
+ [bx, vx, vy] = [1.5, Math.min(1.4, -vx * 1.05), (by - (you + PADDLE / 2 - 0.5)) * 0.5];
54
+ }
55
+ if (vx > 0 && bx >= s.w - 2.5 && bx <= s.w - 1.5 && by >= them - 0.5 && by <= them + PADDLE - 0.5) {
56
+ [bx, vx, vy] = [s.w - 2.5, -Math.min(1.4, vx * 1.05), (by - (them + PADDLE / 2 - 0.5)) * 0.5];
57
+ }
58
+ const next = { ...s, you, them, bx, by, vx, vy };
59
+ if (bx < 0)
60
+ return serve({ ...next, scores: [s.scores[0], s.scores[1] + 1] }, -1);
61
+ if (bx > s.w - 1)
62
+ return serve({ ...next, scores: [s.scores[0] + 1, s.scores[1]] }, 1);
63
+ return next;
64
+ },
65
+ view(s) {
66
+ const grid = blank(s.w, s.h);
67
+ for (let y = 0; y < s.h; y += 2)
68
+ grid[y][Math.floor(s.w / 2)] = "┊";
69
+ for (let k = 0; k < PADDLE; k++) {
70
+ grid[s.you + k][1] = "█";
71
+ grid[Math.round(s.them) + k][s.w - 2] = "█";
72
+ }
73
+ const bx = Math.round(s.bx);
74
+ const by = Math.round(s.by);
75
+ if (grid[by]?.[bx] !== undefined)
76
+ grid[by][bx] = "●";
77
+ const [a, b] = s.scores;
78
+ return {
79
+ lines: grid.map((r) => r.join("")),
80
+ status: `you ${a} · ${b} computer ↑↓ move`,
81
+ ...(a >= PONG_WIN ? { over: "You win! ✦" } : b >= PONG_WIN ? { over: "The computer wins." } : {}),
82
+ };
83
+ },
84
+ };
85
+ const DIRS = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };
86
+ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
87
+ function placeFood(s) {
88
+ for (let tries = 0; tries < 500; tries++) {
89
+ const p = { x: Math.floor(s.random() * s.w), y: Math.floor(s.random() * s.h) };
90
+ if (!s.body.some((b) => b.x === p.x && b.y === p.y))
91
+ return p;
92
+ }
93
+ return { x: 0, y: 0 };
94
+ }
95
+ export const snake = {
96
+ id: "snake",
97
+ title: "Snake",
98
+ blurb: "Eat, grow, don't bite yourself or the walls.",
99
+ controls: "arrows or wasd steer",
100
+ tickMs: 110,
101
+ colors: { "█": "#98c379", "▓": "#7ec16e", "◆": "#e06c75" },
102
+ create(w, h, seed = Date.now()) {
103
+ const y = Math.floor(h / 2);
104
+ const s = { w, h, body: [{ x: 4, y }, { x: 3, y }, { x: 2, y }], dir: "right", food: { x: 0, y: 0 }, score: 0, dead: false, random: rng(seed) };
105
+ return { ...s, food: placeFood(s) };
106
+ },
107
+ tick(s, inputs) {
108
+ if (s.dead)
109
+ return s;
110
+ let dir = s.dir;
111
+ // One turn per tick (the first that isn't straight back), so quick double taps don't fold it onto itself.
112
+ for (const i of inputs) {
113
+ if (i !== OPPOSITE[dir] && i !== dir) {
114
+ dir = i;
115
+ break;
116
+ }
117
+ }
118
+ const head = { x: s.body[0].x + DIRS[dir].x, y: s.body[0].y + DIRS[dir].y };
119
+ const eats = head.x === s.food.x && head.y === s.food.y;
120
+ const rest = eats ? s.body : s.body.slice(0, -1);
121
+ const dead = head.x < 0 || head.y < 0 || head.x >= s.w || head.y >= s.h || rest.some((b) => b.x === head.x && b.y === head.y);
122
+ if (dead)
123
+ return { ...s, dir, dead: true };
124
+ const next = { ...s, dir, body: [head, ...rest], score: s.score + (eats ? 1 : 0) };
125
+ return eats ? { ...next, food: placeFood(next) } : next;
126
+ },
127
+ view(s) {
128
+ const grid = blank(s.w, s.h);
129
+ grid[s.food.y][s.food.x] = "◆";
130
+ s.body.forEach((b, i) => {
131
+ if (grid[b.y]?.[b.x] !== undefined)
132
+ grid[b.y][b.x] = i === 0 ? "█" : "▓";
133
+ });
134
+ return { lines: grid.map((r) => r.join("")), status: `length ${s.body.length} · score ${s.score}`, ...(s.dead ? { over: `Game over: ${s.score} eaten.` } : {}) };
135
+ },
136
+ };
137
+ const PAD = 7;
138
+ const BRICK_ROWS = 4;
139
+ function launch(s) {
140
+ return { ...s, bx: s.paddle + PAD / 2, by: s.h - 3, vx: (s.random() < 0.5 ? -1 : 1) * 0.6, vy: -0.6 };
141
+ }
142
+ export const breakout = {
143
+ id: "breakout",
144
+ title: "Breakout",
145
+ blurb: "Knock out every brick with the ball. Three lives.",
146
+ controls: "←→ or a/d move",
147
+ tickMs: 45,
148
+ colors: { "▀": "#e06c75", "▄": "#d19a66", "▬": "#61afef", "●": "#e5c07b" },
149
+ create(w, h, seed = Date.now()) {
150
+ const s = {
151
+ w, h, paddle: Math.floor((w - PAD) / 2), bx: 0, by: 0, vx: 0, vy: 0,
152
+ bricks: Array.from({ length: BRICK_ROWS }, () => Array(Math.floor(w / 3)).fill(true)), lives: 3, score: 0, random: rng(seed),
153
+ };
154
+ return launch(s);
155
+ },
156
+ tick(s, inputs) {
157
+ if (s.lives <= 0 || s.bricks.every((r) => r.every((b) => !b)))
158
+ return s;
159
+ let paddle = s.paddle;
160
+ for (const i of inputs)
161
+ paddle += i === "left" ? -2 : i === "right" ? 2 : 0;
162
+ paddle = clamp(paddle, 0, s.w - PAD);
163
+ let { bx, by, vx, vy } = s;
164
+ bx += vx;
165
+ by += vy;
166
+ if (bx < 0 || bx > s.w - 1)
167
+ [bx, vx] = [clamp(bx, 0, s.w - 1), -vx];
168
+ if (by < 0)
169
+ [by, vy] = [0, -vy];
170
+ // Bricks are three columns wide, on rows 1.. so there's a gap at the top.
171
+ const bricks = s.bricks.map((r) => [...r]);
172
+ let score = s.score;
173
+ const row = Math.floor(by) - 1;
174
+ const col = Math.floor(bx / 3);
175
+ if (row >= 0 && row < BRICK_ROWS && bricks[row]?.[col]) {
176
+ bricks[row][col] = false;
177
+ score += (BRICK_ROWS - row) * 10;
178
+ vy = -vy;
179
+ }
180
+ // The paddle sits on the last row; where the ball lands on it sets the angle.
181
+ if (vy > 0 && by >= s.h - 2 && by < s.h - 1 && bx >= paddle - 0.5 && bx <= paddle + PAD - 0.5) {
182
+ vy = -Math.abs(vy);
183
+ vx = clamp((bx - (paddle + PAD / 2 - 0.5)) * 0.25, -1, 1) || 0.3;
184
+ by = s.h - 2;
185
+ }
186
+ const next = { ...s, paddle, bx, by, vx, vy, bricks, score };
187
+ if (by > s.h - 1) {
188
+ const lives = s.lives - 1;
189
+ return lives > 0 ? launch({ ...next, lives }) : { ...next, lives };
190
+ }
191
+ return next;
192
+ },
193
+ view(s) {
194
+ const grid = blank(s.w, s.h);
195
+ s.bricks.forEach((r, y) => r.forEach((alive, x) => {
196
+ if (alive)
197
+ for (let k = 0; k < 3; k++)
198
+ if (x * 3 + k < s.w - 0)
199
+ grid[y + 1][x * 3 + k] = y % 2 ? "▄" : "▀";
200
+ }));
201
+ for (let k = 0; k < PAD; k++)
202
+ grid[s.h - 1][s.paddle + k] = "▬";
203
+ const bx = Math.round(s.bx);
204
+ const by = Math.round(s.by);
205
+ if (s.lives > 0 && grid[by]?.[bx] !== undefined)
206
+ grid[by][bx] = "●";
207
+ const cleared = s.bricks.every((r) => r.every((b) => !b));
208
+ return {
209
+ lines: grid.map((r) => r.join("")),
210
+ status: `score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
211
+ ...(cleared ? { over: `Cleared! ${s.score} points ✦` } : s.lives <= 0 ? { over: `Game over: ${s.score} points.` } : {}),
212
+ };
213
+ },
214
+ };
215
+ /** Every game Nou has, in the order settings lists them. */
216
+ export const GAMES = [pong, snake, breakout];
217
+ export const gameById = (id) => GAMES.find((g) => g.id === id);