nou-cli 0.5.1 → 0.5.3
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/README.md +48 -9
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +199 -33
- package/dist/src/cli.js +117 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +23 -3
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +900 -118
- package/dist/src/ui/Chat.js +143 -22
- package/dist/src/ui/ChatInput.js +9 -2
- package/dist/src/ui/CodeView.js +111 -20
- package/dist/src/ui/Editor.js +13 -6
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +99 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +56 -0
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +36 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +4 -1
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +132 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
/** Files looked at per tool: the newest ones. */
|
|
5
|
+
const SCAN = 300;
|
|
6
|
+
const claudeDir = (home) => process.env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
|
|
7
|
+
const codexDir = (home) => process.env.CODEX_HOME ?? path.join(home, ".codex");
|
|
8
|
+
function jsonLines(file, limit = Infinity) {
|
|
9
|
+
const out = [];
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = readFileSync(file, "utf-8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
for (const line of text.split("\n")) {
|
|
18
|
+
if (out.length >= limit)
|
|
19
|
+
break;
|
|
20
|
+
if (!line.trim())
|
|
21
|
+
continue;
|
|
22
|
+
try {
|
|
23
|
+
out.push(JSON.parse(line));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// a line being written
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** The start of a file, for the line that says which folder a Codex session ran in. */
|
|
32
|
+
function head(file, bytes = 8192) {
|
|
33
|
+
const fd = openSync(file, "r");
|
|
34
|
+
try {
|
|
35
|
+
const buf = Buffer.alloc(bytes);
|
|
36
|
+
return buf.subarray(0, readSync(fd, buf, 0, bytes, 0)).toString("utf-8");
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Plain text from a message's content (a string, or parts with text). */
|
|
43
|
+
function textOf(content) {
|
|
44
|
+
if (typeof content === "string")
|
|
45
|
+
return content;
|
|
46
|
+
if (!Array.isArray(content))
|
|
47
|
+
return "";
|
|
48
|
+
return content.filter((p) => typeof p?.text === "string" && /^(text|input_text|output_text)$/.test(p.type ?? "text")).map((p) => p.text).join("\n");
|
|
49
|
+
}
|
|
50
|
+
/** What the tools add on their own (context blocks, command echoes): not something you typed. */
|
|
51
|
+
const injected = (text) => /^\s*<(environment_context|user_instructions|command-|local-command|system-reminder)/.test(text) || text.startsWith("Caveat:");
|
|
52
|
+
const titleOf = (text) => text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
53
|
+
/** Claude Code's sessions for this folder. */
|
|
54
|
+
function claudeSessions(home, cwd) {
|
|
55
|
+
const dir = path.join(claudeDir(home), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"));
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
return [];
|
|
58
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(dir, f));
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const file of newest(files)) {
|
|
61
|
+
const said = claudeEntries(file);
|
|
62
|
+
const first = said.find((e) => e.kind === "user");
|
|
63
|
+
if (!first)
|
|
64
|
+
continue;
|
|
65
|
+
out.push({ id: path.basename(file, ".jsonl"), engine: "claude-code", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function claudeEntries(file) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of jsonLines(file)) {
|
|
72
|
+
if (line.isSidechain || line.isMeta)
|
|
73
|
+
continue;
|
|
74
|
+
if (line.type === "user" && line.message?.role === "user") {
|
|
75
|
+
const text = textOf(line.message.content);
|
|
76
|
+
if (text.trim() && !injected(text))
|
|
77
|
+
out.push({ kind: "user", text: text.trim() });
|
|
78
|
+
}
|
|
79
|
+
else if (line.type === "assistant") {
|
|
80
|
+
const text = textOf(line.message?.content);
|
|
81
|
+
if (text.trim())
|
|
82
|
+
out.push({ kind: "assistant", text: text.trim() });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Codex's sessions for this folder (it keeps them all in one tree, by date). */
|
|
88
|
+
function codexSessions(home, cwd) {
|
|
89
|
+
const root = path.join(codexDir(home), "sessions");
|
|
90
|
+
if (!existsSync(root))
|
|
91
|
+
return [];
|
|
92
|
+
const files = [];
|
|
93
|
+
const walk = (dir, depth) => {
|
|
94
|
+
for (const name of readdirSync(dir).sort().reverse()) {
|
|
95
|
+
if (files.length >= SCAN)
|
|
96
|
+
return;
|
|
97
|
+
const full = path.join(dir, name);
|
|
98
|
+
if (depth < 3)
|
|
99
|
+
walk(full, depth + 1);
|
|
100
|
+
else if (name.startsWith("rollout-") && name.endsWith(".jsonl"))
|
|
101
|
+
files.push(full);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
walk(root, 0);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
let meta;
|
|
113
|
+
try {
|
|
114
|
+
meta = JSON.parse(head(file).split("\n")[0]);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const payload = meta?.type === "session_meta" ? meta.payload : null;
|
|
120
|
+
if (!payload?.id || path.resolve(payload.cwd ?? "") !== path.resolve(cwd))
|
|
121
|
+
continue;
|
|
122
|
+
const said = codexEntries(file);
|
|
123
|
+
const first = said.find((e) => e.kind === "user");
|
|
124
|
+
if (!first)
|
|
125
|
+
continue;
|
|
126
|
+
out.push({ id: payload.id, engine: "codex", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function codexEntries(file) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const line of jsonLines(file)) {
|
|
133
|
+
const p = line.type === "response_item" ? line.payload : null;
|
|
134
|
+
if (p?.type !== "message")
|
|
135
|
+
continue;
|
|
136
|
+
const text = textOf(p.content).trim();
|
|
137
|
+
if (!text || injected(text))
|
|
138
|
+
continue;
|
|
139
|
+
if (p.role === "user")
|
|
140
|
+
out.push({ kind: "user", text });
|
|
141
|
+
else if (p.role === "assistant")
|
|
142
|
+
out.push({ kind: "assistant", text });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function newest(files) {
|
|
147
|
+
return files.map((f) => ({ f, t: statSync(f).mtimeMs })).sort((a, b) => b.t - a.t).slice(0, SCAN).map((x) => x.f);
|
|
148
|
+
}
|
|
149
|
+
/** Claude Code's and Codex's own sessions in this folder, latest first. */
|
|
150
|
+
export function externalSessions(home = os.homedir(), cwd = process.cwd()) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const read of [claudeSessions, codexSessions]) {
|
|
153
|
+
try {
|
|
154
|
+
out.push(...read(home, cwd));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// a tool's files we can't read: skip them
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out.sort((a, b) => b.updated - a.updated);
|
|
161
|
+
}
|
|
162
|
+
/** The chat of an external session, to show when it's resumed. */
|
|
163
|
+
export function externalEntries(session) {
|
|
164
|
+
return session.engine === "claude-code" ? claudeEntries(session.file) : codexEntries(session.file);
|
|
165
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { NOU_HOME } from "../config/index.js";
|
|
5
|
+
/** Kept per project: the oldest go once there are more. */
|
|
6
|
+
const KEEP = 50;
|
|
7
|
+
/** Chat entries saved per conversation (the latest ones). */
|
|
8
|
+
const MAX_ENTRIES = 500;
|
|
9
|
+
function folder(cwd = process.cwd()) {
|
|
10
|
+
const name = path.basename(cwd).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
|
|
11
|
+
return path.join(NOU_HOME, "conversations", `${name}-${createHash("sha1").update(cwd).digest("hex").slice(0, 10)}`);
|
|
12
|
+
}
|
|
13
|
+
export function saveConversation(conversation) {
|
|
14
|
+
const dir = folder();
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
const entries = conversation.entries.slice(-MAX_ENTRIES);
|
|
19
|
+
writeFileSync(path.join(dir, `${conversation.id}.json`), JSON.stringify({ ...conversation, entries }), { mode: 0o600 });
|
|
20
|
+
const old = listConversations().slice(KEEP);
|
|
21
|
+
for (const c of old)
|
|
22
|
+
unlinkSync(path.join(dir, `${c.id}.json`));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Saving is a convenience: a read-only home shouldn't break the chat.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** This project's conversations, latest first. */
|
|
29
|
+
export function listConversations() {
|
|
30
|
+
const dir = folder();
|
|
31
|
+
if (!existsSync(dir))
|
|
32
|
+
return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const file of readdirSync(dir)) {
|
|
35
|
+
if (!file.endsWith(".json"))
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const c = JSON.parse(readFileSync(path.join(dir, file), "utf-8"));
|
|
39
|
+
const state = c.state;
|
|
40
|
+
const ref = state?.claudeSession ?? state?.codexThread;
|
|
41
|
+
out.push({ id: c.id, title: c.title, updated: c.updated, engine: c.engine, messages: c.entries.filter((e) => e.kind === "user").length, ...(typeof ref === "string" ? { ref } : {}) });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// a damaged file is skipped
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.sort((a, b) => b.updated - a.updated);
|
|
48
|
+
}
|
|
49
|
+
export function loadConversation(id) {
|
|
50
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(path.join(folder(), `${id}.json`), "utf-8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** "5m ago", "yesterday"... */
|
|
60
|
+
export function ago(time, now = Date.now()) {
|
|
61
|
+
const s = Math.max(0, Math.round((now - time) / 1000));
|
|
62
|
+
if (s < 60)
|
|
63
|
+
return "just now";
|
|
64
|
+
if (s < 3600)
|
|
65
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
66
|
+
if (s < 86_400)
|
|
67
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
68
|
+
if (s < 172_800)
|
|
69
|
+
return "yesterday";
|
|
70
|
+
return `${Math.floor(s / 86_400)}d ago`;
|
|
71
|
+
}
|
|
@@ -50,6 +50,16 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
50
50
|
resetConversation() {
|
|
51
51
|
this.claudeSession = undefined;
|
|
52
52
|
}
|
|
53
|
+
snapshot() {
|
|
54
|
+
return this.claudeSession ? { claudeSession: this.claudeSession } : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Claude Code keeps its sessions on disk, so a saved one resumes in a later run too. */
|
|
57
|
+
restore(state) {
|
|
58
|
+
if (typeof state?.claudeSession !== "string")
|
|
59
|
+
return false;
|
|
60
|
+
this.claudeSession = state.claudeSession;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
53
63
|
/** Claude Code can resume its session with another model, so the conversation carries over. */
|
|
54
64
|
setModel(choice) {
|
|
55
65
|
this.choice = choice;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
2
|
import { statSync } from "fs";
|
|
3
|
+
import { transientMessage } from "../agent/index.js";
|
|
3
4
|
import { listAllFiles } from "../ui/QuickOpen.js";
|
|
4
5
|
import { ExternalEngine } from "./external.js";
|
|
5
6
|
import { loadPackage } from "./index.js";
|
|
6
7
|
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
7
8
|
const MAX_FILES = 5000;
|
|
8
9
|
const MAX_BYTES = 512 * 1024;
|
|
10
|
+
/** Times a turn that failed for a passing reason (a dropped stream, a failing server) is picked up again. */
|
|
11
|
+
const RESUMES = 5;
|
|
9
12
|
/** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
|
|
10
13
|
function projectFiles() {
|
|
11
14
|
let files;
|
|
@@ -42,34 +45,82 @@ export class CodexEngine extends ExternalEngine {
|
|
|
42
45
|
id = "codex";
|
|
43
46
|
thread = null;
|
|
44
47
|
introduced = false;
|
|
48
|
+
/** A saved thread to pick back up (/resume): Codex keeps its threads on disk. */
|
|
49
|
+
resumeId;
|
|
45
50
|
constructor(choice, hooks) {
|
|
46
51
|
super(choice, hooks, "Codex");
|
|
47
52
|
}
|
|
48
53
|
resetConversation() {
|
|
49
54
|
this.thread = null;
|
|
50
55
|
this.introduced = false;
|
|
56
|
+
this.resumeId = undefined;
|
|
57
|
+
}
|
|
58
|
+
snapshot() {
|
|
59
|
+
const id = this.thread?.id ?? this.resumeId;
|
|
60
|
+
return id ? { codexThread: id } : undefined;
|
|
61
|
+
}
|
|
62
|
+
restore(state) {
|
|
63
|
+
if (typeof state?.codexThread !== "string")
|
|
64
|
+
return false;
|
|
65
|
+
this.thread = null;
|
|
66
|
+
this.resumeId = state.codexThread;
|
|
67
|
+
this.introduced = true; // it already has the project context
|
|
68
|
+
return true;
|
|
51
69
|
}
|
|
52
70
|
async run(prompt, signal) {
|
|
53
71
|
const { Codex } = await loadPackage("@openai/codex-sdk");
|
|
54
72
|
// Codex uses its own login (`codex login`); an OpenAI key from Nou's setup works too.
|
|
55
73
|
const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
|
|
56
|
-
|
|
74
|
+
const options = {
|
|
57
75
|
workingDirectory: process.cwd(),
|
|
58
76
|
skipGitRepoCheck: true,
|
|
59
77
|
sandboxMode: "workspace-write",
|
|
78
|
+
// Its sandbox blocks the network by default, so npm/pip installs and tests that fetch fail ("can't
|
|
79
|
+
// resolve registry.npmjs.org"); Nou's other engines can reach it, so Codex can too.
|
|
80
|
+
networkAccessEnabled: true,
|
|
60
81
|
approvalPolicy: "never",
|
|
61
82
|
...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
|
|
62
83
|
...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
|
|
63
|
-
}
|
|
84
|
+
};
|
|
85
|
+
const codex = new Codex(apiKey ? { apiKey } : {});
|
|
86
|
+
this.thread ??= this.resumeId ? codex.resumeThread(this.resumeId, options) : codex.startThread(options);
|
|
64
87
|
// Codex has no system prompt option; Nou's project context goes with the first message.
|
|
65
88
|
const input = !this.introduced && this.extras.length ? `${this.extras.join("\n\n")}\n\n${prompt}` : prompt;
|
|
66
89
|
this.introduced = true;
|
|
67
90
|
this.tracker.remember(projectFiles());
|
|
91
|
+
// Codex retries a dropped stream itself ("Reconnecting... 2/5"); when it gives up, Nou picks the turn up
|
|
92
|
+
// again on the same thread, waiting longer each time, before calling it failed.
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
const failure = await this.runTurn(attempt ? "The connection dropped before you finished. Carry on where you left off." : input, signal);
|
|
95
|
+
if (!failure)
|
|
96
|
+
return;
|
|
97
|
+
if (attempt >= RESUMES || !transientMessage(failure))
|
|
98
|
+
throw new Error(explain(failure));
|
|
99
|
+
const wait = Math.min(30_000, 2000 * 2 ** attempt);
|
|
100
|
+
this.hooks.onStream("");
|
|
101
|
+
this.hooks.onBusy(`Codex: ${failure.split("\n")[0].slice(0, 70)}. Retrying in ${wait / 1000}s (${attempt + 1}/${RESUMES})...`);
|
|
102
|
+
await new Promise((resolve) => {
|
|
103
|
+
const timer = setTimeout(resolve, wait);
|
|
104
|
+
signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
|
|
105
|
+
});
|
|
106
|
+
if (signal.aborted)
|
|
107
|
+
throw new Error("aborted");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Runs one Codex turn; returns why it failed, if it did. */
|
|
111
|
+
async runTurn(input, signal) {
|
|
68
112
|
let failure;
|
|
69
113
|
try {
|
|
70
114
|
const { events } = await this.thread.runStreamed(input, { signal });
|
|
71
|
-
for await (const event of events)
|
|
115
|
+
for await (const event of events) {
|
|
116
|
+
if (event.type === "turn.completed") {
|
|
117
|
+
// Its turn is over (it may have just asked you something): done now, rather than waiting for the
|
|
118
|
+
// codex process to wind down, which kept "Codex is working..." up under its question.
|
|
119
|
+
failure = undefined; // and it recovered from anything that went wrong on the way
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
72
122
|
failure = (await this.handle(event)) ?? failure;
|
|
123
|
+
}
|
|
73
124
|
}
|
|
74
125
|
catch (error) {
|
|
75
126
|
// The codex process exiting with an error ends the stream; its first line says why.
|
|
@@ -79,8 +130,7 @@ export class CodexEngine extends ExternalEngine {
|
|
|
79
130
|
}
|
|
80
131
|
if (signal.aborted)
|
|
81
132
|
throw new Error("aborted");
|
|
82
|
-
|
|
83
|
-
throw new Error(explain(failure));
|
|
133
|
+
return failure;
|
|
84
134
|
}
|
|
85
135
|
/** Shows one Codex event; returns the error message if the turn failed. */
|
|
86
136
|
async handle(event) {
|
|
@@ -124,6 +174,11 @@ export class CodexEngine extends ExternalEngine {
|
|
|
124
174
|
return event.error.message;
|
|
125
175
|
}
|
|
126
176
|
else if (event.type === "error") {
|
|
177
|
+
// "Reconnecting... 1/5 (stream disconnected ...)": Codex is retrying, so it's progress, not the end.
|
|
178
|
+
if (/^Reconnecting\b/i.test(event.message)) {
|
|
179
|
+
this.hooks.onBusy(`Codex: ${event.message.split("\n")[0].slice(0, 100)}`);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
127
182
|
return event.message;
|
|
128
183
|
}
|
|
129
184
|
return undefined;
|
|
@@ -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);
|