pi-crew 0.9.26 → 0.9.28

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +245 -48
  6. package/dist/index.mjs +1611 -243
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/agent-config.ts +2 -0
  35. package/src/agents/discover-agents.ts +10 -1
  36. package/src/config/defaults.ts +6 -0
  37. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  38. package/src/extension/crew-vibes/config.ts +195 -0
  39. package/src/extension/crew-vibes/figures.ts +94 -0
  40. package/src/extension/crew-vibes/font-detect.ts +58 -0
  41. package/src/extension/crew-vibes/index.ts +385 -0
  42. package/src/extension/crew-vibes/provider-usage.ts +396 -0
  43. package/src/extension/crew-vibes/render.ts +206 -0
  44. package/src/extension/crew-vibes/speed.ts +286 -0
  45. package/src/extension/knowledge-injection.ts +12 -3
  46. package/src/extension/management.ts +23 -3
  47. package/src/extension/register.ts +7 -0
  48. package/src/extension/team-tool.ts +11 -0
  49. package/src/prompt/prompt-runtime.ts +65 -0
  50. package/src/runtime/child-pi.ts +123 -10
  51. package/src/runtime/crew-agent-records.ts +10 -3
  52. package/src/runtime/pi-args.ts +2 -0
  53. package/src/runtime/retry-executor.ts +4 -1
  54. package/src/runtime/task-runner/state-helpers.ts +9 -30
  55. package/src/runtime/task-runner.ts +1 -0
  56. package/src/runtime/team-runner.ts +5 -3
  57. package/src/state/atomic-write.ts +153 -49
  58. package/src/state/event-log.ts +16 -10
  59. package/src/state/locks.ts +7 -8
  60. package/src/state/mailbox.ts +15 -4
  61. package/src/state/state-store.ts +39 -10
  62. package/src/teams/discover-teams.ts +56 -1
  63. package/src/utils/safe-paths.ts +2 -1
  64. package/src/workflows/discover-workflows.ts +72 -1
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cold-start benchmark: bundle vs strip-types.
4
+ *
5
+ * Compares two entry paths from a fresh node process:
6
+ * - STRIP-TYPES: node --experimental-strip-types loads src/extension/register.ts
7
+ * (or src/extension/index.ts via the package entry)
8
+ * - BUNDLE: node loads dist/index.mjs (esbuild-bundled 2.9MB single file)
9
+ *
10
+ * For each path we spawn N child node processes and measure:
11
+ * - totalMs: time from process spawn to its exit
12
+ * - importMs: time the child reports for `await import(...)`
13
+ * - registerMs: time the child reports for registerPiTeams(mockPi)
14
+ *
15
+ * We report:
16
+ * - per-path median, p95, max for each metric
17
+ * - bundle vs strip-types speedup (negative = bundle faster)
18
+ *
19
+ * Usage:
20
+ * node scripts/bench-cold-start.mjs # default 10 iters
21
+ * node scripts/bench-cold-start.mjs --iters 20
22
+ *
23
+ * Output goes to stdout as JSON plus a human-readable summary.
24
+ */
25
+ import { execFileSync, spawnSync } from "node:child_process";
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { pathToFileURL } from "node:url";
29
+
30
+ const root = path.resolve(import.meta.dirname, "..");
31
+ const args = process.argv.slice(2);
32
+ let iters = 10;
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === "--iters" && args[i + 1]) {
35
+ iters = Number(args[i + 1]);
36
+ i++;
37
+ }
38
+ }
39
+
40
+ // Driver template. We capture wall-clock per sample using performance.now()
41
+ // and emit JSON lines (one per sample). The outer harness parses them.
42
+ const driverTemplate = (entrySpec, registerImport) => `
43
+ import { performance } from "node:perf_hooks";
44
+
45
+ const tSpawn = performance.now();
46
+
47
+ function createEvents() {
48
+ const handlers = new Map();
49
+ return {
50
+ on(event, handler) {
51
+ const set = handlers.get(event) ?? new Set();
52
+ set.add(handler);
53
+ handlers.set(event, set);
54
+ return () => set.delete(handler);
55
+ },
56
+ emit(event, payload) {
57
+ for (const h of handlers.get(event) ?? []) h(payload);
58
+ },
59
+ };
60
+ }
61
+
62
+ const mockPi = {
63
+ cwd: process.cwd(),
64
+ events: createEvents(),
65
+ hasUI: false,
66
+ getFlag: () => undefined,
67
+ getAllFlags: () => ({}),
68
+ settings: {},
69
+ storage: undefined,
70
+ on() {},
71
+ registerTool() {},
72
+ registerCommand() {},
73
+ appendEntry: async () => undefined,
74
+ sendMessage: async () => undefined,
75
+ sendUserMessage: async () => undefined,
76
+ };
77
+
78
+ const tImport0 = performance.now();
79
+ const entry = await import(${JSON.stringify(entrySpec)});
80
+ const tImportMs = performance.now() - tImport0;
81
+
82
+ const register = ${registerImport};
83
+
84
+ const tReg0 = performance.now();
85
+ register(mockPi);
86
+ const tRegMs = performance.now() - tReg0;
87
+
88
+ const tTotal = performance.now();
89
+ process.stdout.write(JSON.stringify({
90
+ total: tTotal - tSpawn,
91
+ import: tImportMs,
92
+ register: tRegMs,
93
+ defaultKind: typeof entry.default,
94
+ hasRegister: typeof ${registerImport === "entry.registerPiTeams" ? "entry.registerPiTeams" : "register"},
95
+ }) + "\\n");
96
+ `;
97
+
98
+ async function runPath(label, entrySpec, registerImport, itersCount) {
99
+ const driverPath = path.join(root, `.bench-${label}-${process.pid}.mjs`);
100
+ const driverSrc = driverTemplate(entrySpec, registerImport);
101
+ fs.writeFileSync(driverPath, driverSrc, "utf-8");
102
+
103
+ const cmd = label === "strip-types"
104
+ ? ["--experimental-strip-types", "--no-warnings", driverPath]
105
+ : ["--no-warnings", driverPath];
106
+
107
+ const samples = [];
108
+ for (let i = 0; i < itersCount; i++) {
109
+ const t0 = performance.now();
110
+ const r = spawnSync(process.execPath, cmd, { encoding: "utf-8", cwd: root });
111
+ const wall = performance.now() - t0;
112
+ if (r.status !== 0) {
113
+ console.error(`[bench:${label}] iter ${i} failed:`, r.stderr?.slice(0, 200));
114
+ continue;
115
+ }
116
+ const lastLine = r.stdout.trim().split("\n").pop();
117
+ try {
118
+ const sample = JSON.parse(lastLine);
119
+ samples.push({ ...sample, wall });
120
+ } catch {
121
+ console.error(`[bench:${label}] iter ${i} parse error:`, lastLine);
122
+ }
123
+ }
124
+
125
+ fs.unlinkSync(driverPath);
126
+ return samples;
127
+ }
128
+
129
+ function stats(arr) {
130
+ const sorted = [...arr].sort((a, b) => a - b);
131
+ const p = (q) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * q))];
132
+ return {
133
+ n: sorted.length,
134
+ min: sorted[0],
135
+ p50: p(0.5),
136
+ p95: p(0.95),
137
+ max: sorted[sorted.length - 1],
138
+ mean: sorted.reduce((s, x) => s + x, 0) / sorted.length,
139
+ };
140
+ }
141
+
142
+ function pctDelta(newer, older) {
143
+ // positive = newer is slower than older
144
+ return (((newer - older) / older) * 100).toFixed(1);
145
+ }
146
+
147
+ console.log(`[bench] Running ${iters} iters per path...`);
148
+ console.log("");
149
+
150
+ const stripSpec = pathToFileURL(path.join(root, "src/extension/register.ts")).href;
151
+ const stripRegister = "(await import(" + JSON.stringify(stripSpec) + ")).registerPiTeams";
152
+ const stripSamples = await runPath("strip-types", stripSpec, stripRegister, iters);
153
+
154
+ const bundleSpec = pathToFileURL(path.join(root, "dist/index.mjs")).href;
155
+ const bundleRegister = "(await import(" + JSON.stringify(bundleSpec) + ")).registerPiTeams";
156
+ const bundleSamples = await runPath("bundle", bundleSpec, bundleRegister, iters);
157
+
158
+ if (stripSamples.length === 0 || bundleSamples.length === 0) {
159
+ console.error("[bench] insufficient samples; aborting");
160
+ process.exit(1);
161
+ }
162
+
163
+ const summary = {
164
+ timestamp: new Date().toISOString(),
165
+ iters,
166
+ strip: {
167
+ total: stats(stripSamples.map((s) => s.total)),
168
+ import: stats(stripSamples.map((s) => s.import)),
169
+ register: stats(stripSamples.map((s) => s.register)),
170
+ wall: stats(stripSamples.map((s) => s.wall)),
171
+ },
172
+ bundle: {
173
+ total: stats(bundleSamples.map((s) => s.total)),
174
+ import: stats(bundleSamples.map((s) => s.import)),
175
+ register: stats(bundleSamples.map((s) => s.register)),
176
+ wall: stats(bundleSamples.map((s) => s.wall)),
177
+ },
178
+ };
179
+
180
+ // Speedup = strip - bundle (positive = bundle faster)
181
+ summary.speedup = {
182
+ total: summary.strip.total.p50 - summary.bundle.total.p50,
183
+ totalPct: pctDelta(summary.bundle.total.p50, summary.strip.total.p50),
184
+ import: summary.strip.import.p50 - summary.bundle.import.p50,
185
+ importPct: pctDelta(summary.bundle.import.p50, summary.strip.import.p50),
186
+ register: summary.strip.register.p50 - summary.bundle.register.p50,
187
+ registerPct: pctDelta(summary.bundle.register.p50, summary.strip.register.p50),
188
+ wall: summary.strip.wall.p50 - summary.bundle.wall.p50,
189
+ wallPct: pctDelta(summary.bundle.wall.p50, summary.strip.wall.p50),
190
+ };
191
+
192
+ // Print human-readable summary
193
+ console.log("=== Cold-start benchmark: strip-types vs bundle ===\n");
194
+
195
+ const printPath = (label, data) => {
196
+ console.log(` ${label}:`);
197
+ console.log(` total (child process wall-clock): p50=${data.total.p50.toFixed(1)}ms p95=${data.total.p95.toFixed(1)}ms min=${data.total.min.toFixed(1)}ms`);
198
+ console.log(` import (await import(...)): p50=${data.import.p50.toFixed(1)}ms p95=${data.import.p95.toFixed(1)}ms`);
199
+ console.log(` register (registerPiTeams(mockPi)): p50=${data.register.p50.toFixed(1)}ms p95=${data.register.p95.toFixed(1)}ms`);
200
+ console.log("");
201
+ };
202
+
203
+ printPath("STRIP-TYPES (--experimental-strip-types + 1100 .ts files)", summary.strip);
204
+ printPath("BUNDLE (dist/index.mjs, 2.9MB single file)", summary.bundle);
205
+
206
+ console.log(` Speedup (bundle vs strip-types, p50):`);
207
+ console.log(` total: ${summary.speedup.total >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.total).toFixed(1)}ms (${summary.speedup.totalPct}%)`);
208
+ console.log(` import: ${summary.speedup.import >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.import).toFixed(1)}ms (${summary.speedup.importPct}%)`);
209
+ console.log(` register: ${summary.speedup.register >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.register).toFixed(1)}ms (${summary.speedup.registerPct}%)`);
210
+ console.log(` outer wall: ${summary.speedup.wall >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.wall).toFixed(1)}ms (${summary.speedup.wallPct}%)`);
211
+
212
+ const benchDir = path.join(root, ".bench");
213
+ fs.mkdirSync(benchDir, { recursive: true });
214
+ const outFile = path.join(benchDir, `cold-start-${Date.now()}.json`);
215
+ fs.writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n", "utf-8");
216
+ console.log(`\n[bench] Raw results: ${outFile}`);
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 5.5 — Bundle pi-crew into a single ESM file using esbuild.
4
+ *
5
+ * Output:
6
+ * dist/index.mjs — bundled extension entrypoint
7
+ * dist/index.mjs.map — source map
8
+ *
9
+ * Pi peer dependencies are kept external. Bundling shrinks parse+module-
10
+ * resolution cost on cold start: with strip-types Node still has to parse
11
+ * each .ts file individually, so a single .mjs cuts the per-file overhead.
12
+ *
13
+ * This script is invoked by `npm run build:bundle`. The `package.json#exports`
14
+ * field is configured so:
15
+ * - `dist/index.mjs` is the preferred entrypoint when present (set by Pi
16
+ * extension loader via "pi.extensions").
17
+ * - `index.ts` remains the fallback when dist/ is missing (e.g. running
18
+ * directly out of a clone without prior build).
19
+ */
20
+ import { build } from "esbuild";
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const here = path.dirname(fileURLToPath(import.meta.url));
26
+ const root = path.resolve(here, "..");
27
+
28
+ const distDir = path.join(root, "dist");
29
+ fs.mkdirSync(distDir, { recursive: true });
30
+
31
+ const start = Date.now();
32
+ const result = await build({
33
+ // Bundle entry must be the bare extension (index.bundle.ts) NOT the
34
+ // entry shell (index.ts). If we bundled index.ts, the bundled code
35
+ // would re-resolve dist/index.mjs relative to ITS OWN location
36
+ // (file:///.../dist/index.mjs), producing dist/dist/index.mjs and a
37
+ // recursion error. index.bundle.ts has no shell logic so this is
38
+ // a clean single-file bundle. See index.bundle.ts header for details.
39
+ entryPoints: [path.join(root, "index.bundle.ts")],
40
+ bundle: true,
41
+ format: "esm",
42
+ platform: "node",
43
+ target: "node22",
44
+ outfile: path.join(distDir, "index.mjs"),
45
+ sourcemap: true,
46
+ logLevel: "info",
47
+ // Keep peer deps external so consumers' Pi versions resolve naturally.
48
+ external: [
49
+ "@earendil-works/pi-coding-agent",
50
+ "@earendil-works/pi-ai",
51
+ "@earendil-works/pi-agent-core",
52
+ "@earendil-works/pi-tui",
53
+ // Direct deps as well — bundling their full graph would inflate the
54
+ // output and override consumer-installed versions.
55
+ "cli-highlight",
56
+ "diff",
57
+ "jiti",
58
+ "typebox",
59
+ ],
60
+ // All node:* and Node-builtin modules are external by default for
61
+ // platform=node, but list explicitly for clarity.
62
+ //
63
+ // CJS-shim banner: pi-crew's dependency graph includes CommonJS modules
64
+ // (notably `yaml`) whose source calls `require("process")` etc. esbuild
65
+ // emits these as runtime `__require(...)` calls; in a pure-ESM context
66
+ // `require` is undefined, so we inject a `createRequire`-backed shim
67
+ // at the top of the bundle. This is the standard pattern for shipping
68
+ // CJS-mixed bundles as `.mjs`. See phase-2 H2 investigation (2026-06-30).
69
+ banner: {
70
+ js:
71
+ "// pi-crew bundled by scripts/build-bundle.mjs (5.5)\n" +
72
+ "// CJS-shim for legacy deps (yaml, etc.) that call require() in ESM context.\n" +
73
+ "import { createRequire as __piCrewCreateRequire } from 'node:module';\n" +
74
+ "const require = __piCrewCreateRequire(import.meta.url);\n" +
75
+ "const module = { exports: {} };\n" +
76
+ "const exports = module.exports;\n",
77
+ },
78
+ metafile: true,
79
+ });
80
+
81
+ fs.writeFileSync(path.join(distDir, "build-meta.json"), JSON.stringify(result.metafile, null, 2) + "\n", "utf-8");
82
+ const elapsedMs = Date.now() - start;
83
+ const stat = fs.statSync(path.join(distDir, "index.mjs"));
84
+ console.log(`[build-bundle] dist/index.mjs ${(stat.size / 1024).toFixed(1)} KB in ${elapsedMs} ms`);
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """Build assets/crew-vibes.ttf.
3
+
4
+ Speed indicator (16 runner frames, U+E700..U+E70F):
5
+ traced from assets/runner-spritesheet.png (a 4x4 silhouette sprite sheet)
6
+ via threshold -> morphological close -> Moore-neighbor boundary trace ->
7
+ Douglas-Peucker simplify -> Chaikin smooth.
8
+
9
+ Capacity meter (6 boat-with-crew stages, U+E710..U+E715):
10
+ drawn as vector silhouettes (half-ellipse hull + circle crew heads).
11
+
12
+ Regenerate after editing the sprite sheet or the capacity builder:
13
+
14
+ python scripts/build-crew-vibes-font.py
15
+ """
16
+ import math
17
+ import os
18
+ from PIL import Image, ImageFilter
19
+ from fontTools.fontBuilder import FontBuilder
20
+ from fontTools.pens.ttGlyphPen import TTGlyphPen
21
+
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ ROOT = os.path.join(HERE, "..")
24
+ SPRITE = os.path.join(ROOT, "assets", "runner-spritesheet.png")
25
+ TTF_OUT = os.path.join(ROOT, "assets", "crew-vibes.ttf")
26
+ UPM = 1000
27
+ ASCENT = 780
28
+ TH = 200 # threshold: pixel < TH is figure
29
+
30
+
31
+ # ---------- image -> contours ----------
32
+
33
+ def load_binary(path):
34
+ im = Image.open(path).convert("L")
35
+ W, H = im.size
36
+ px = im.load()
37
+ return [[px[x, y] < TH for x in range(W)] for y in range(H)], W, H
38
+
39
+
40
+ def detect_rows(b, W, H, min_height=80, gap=12):
41
+ row_sum = [sum(1 for x in range(W) if b[y][x]) for y in range(H)]
42
+ runs = []
43
+ i = 0
44
+ while i < H:
45
+ if row_sum[i] > 0:
46
+ j = i
47
+ while j < H and row_sum[j] > 0:
48
+ j += 1
49
+ if j - i >= min_height:
50
+ runs.append((i, j - 1))
51
+ i = j
52
+ else:
53
+ i += 1
54
+ merged = []
55
+ for r in runs:
56
+ if merged and r[0] - merged[-1][1] <= gap:
57
+ merged[-1] = (merged[-1][0], r[1])
58
+ else:
59
+ merged.append(r)
60
+ return merged
61
+
62
+
63
+ def connected_components_in_band(b, W, y0, y1, min_area=400):
64
+ visited = [[False] * W for _ in range(y1 - y0)]
65
+ comps = []
66
+ for sy in range(y1 - y0):
67
+ for sx in range(W):
68
+ if b[y0 + sy][sx] and not visited[sy][sx]:
69
+ stack = [(sx, sy)]
70
+ visited[sy][sx] = True
71
+ xs, ys, area = [], [], 0
72
+ while stack:
73
+ x, y = stack.pop()
74
+ xs.append(x); ys.append(y); area += 1
75
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
76
+ nx, ny = x + dx, y + dy
77
+ if 0 <= nx < W and 0 <= ny < (y1 - y0) and b[y0 + ny][nx] and not visited[ny][nx]:
78
+ visited[ny][nx] = True
79
+ stack.append((nx, ny))
80
+ if area >= min_area:
81
+ comps.append((min(xs), y0 + min(ys), max(xs) + 1, y0 + max(ys) + 1))
82
+ comps.sort(key=lambda c: c[0])
83
+ return comps
84
+
85
+
86
+ def clean_binary(sub):
87
+ h = len(sub); w = len(sub[0]) if h else 0
88
+ if not h or not w:
89
+ return sub
90
+ im = Image.new("1", (w, h))
91
+ px = im.load()
92
+ for y in range(h):
93
+ for x in range(w):
94
+ px[x, y] = 1 if sub[y][x] else 0
95
+ im = im.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(3))
96
+ px = im.load()
97
+ return [[bool(px[x, y]) for x in range(w)] for y in range(h)]
98
+
99
+
100
+ def trim_bbox(cell):
101
+ h = len(cell); w = len(cell[0]) if h else 0
102
+ xs = [x for y in range(h) for x in range(w) if cell[y][x]]
103
+ ys = [y for y in range(h) for x in range(w) if cell[y][x]]
104
+ if not xs:
105
+ return None
106
+ return (min(xs), min(ys), max(xs) + 1, max(ys) + 1)
107
+
108
+
109
+ def moore_trace(b):
110
+ h = len(b); w = len(b[0]) if h else 0
111
+ start = None
112
+ for y in range(h):
113
+ for x in range(w):
114
+ if b[y][x]:
115
+ start = (x, y); break
116
+ if start:
117
+ break
118
+ if not start:
119
+ return []
120
+ dirs = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
121
+ contour = [start]
122
+ cur = start
123
+ bdir = 4
124
+ while True:
125
+ found = False
126
+ for k in range(1, 9):
127
+ d = (bdir + k) % 8
128
+ nx, ny = cur[0] + dirs[d][0], cur[1] + dirs[d][1]
129
+ if 0 <= nx < w and 0 <= ny < h and b[ny][nx]:
130
+ bdir = (d + 4) % 8
131
+ cur = (nx, ny)
132
+ found = True
133
+ break
134
+ if not found:
135
+ break
136
+ if cur == start:
137
+ break
138
+ contour.append(cur)
139
+ return contour
140
+
141
+
142
+ def point_line_dist(p, a, b):
143
+ if a == b:
144
+ return math.hypot(p[0] - a[0], p[1] - a[1])
145
+ t = ((p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1])) / ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2)
146
+ t = max(0, min(1, t))
147
+ return math.hypot(p[0] - (a[0] + t * (b[0] - a[0])), p[1] - (a[1] + t * (b[1] - a[1])))
148
+
149
+
150
+ def douglas_peucker(points, eps):
151
+ if len(points) < 3:
152
+ return points
153
+ dmax = 0; idx = 0
154
+ for i in range(1, len(points) - 1):
155
+ d = point_line_dist(points[i], points[0], points[-1])
156
+ if d > dmax:
157
+ dmax = d; idx = i
158
+ if dmax > eps:
159
+ left = douglas_peucker(points[: idx + 1], eps)
160
+ right = douglas_peucker(points[idx:], eps)
161
+ return left[:-1] + right
162
+ return [points[0], points[-1]]
163
+
164
+
165
+ def chaikin(points, iters=2):
166
+ pts = points[:]
167
+ for _ in range(iters):
168
+ out = [pts[0]]
169
+ for i in range(len(pts) - 1):
170
+ a, b = pts[i], pts[i + 1]
171
+ out.append((0.75 * a[0] + 0.25 * b[0], 0.75 * a[1] + 0.25 * b[1]))
172
+ out.append((0.25 * a[0] + 0.75 * b[0], 0.25 * a[1] + 0.75 * b[1]))
173
+ out.append(pts[-1])
174
+ pts = out
175
+ return pts
176
+
177
+
178
+ def trace_frame(b, cx0, cy0, cx1, cy1, pad=4, target_h=720):
179
+ cell = [[b[y][x] for x in range(cx0, cx1)] for y in range(cy0, cy1)]
180
+ bbox = trim_bbox(cell)
181
+ if not bbox:
182
+ return None
183
+ x0, y0, x1, y1 = bbox
184
+ px0 = max(0, x0 - pad); py0 = max(0, y0 - pad)
185
+ px1 = min(cx1 - cx0, x1 + pad); py1 = min(cy1 - cy0, y1 + pad)
186
+ sub = [[cell[y][x] for x in range(px0, px1)] for y in range(py0, py1)]
187
+ sub = clean_binary(sub)
188
+ chain = moore_trace(sub)
189
+ if len(chain) < 6:
190
+ return None
191
+ chain = chain + [chain[0]]
192
+ simp = douglas_peucker(chain, 1.5)
193
+ sm = chaikin(simp, 2)
194
+ if len(sm) < 6:
195
+ return None
196
+ pts = [(p[0] + cx0 + px0, p[1] + cy0 + py0) for p in sm]
197
+ xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
198
+ minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
199
+ fh = maxy - miny or 1
200
+ scale = target_h / fh
201
+ contour = [((p[0] - minx) * scale, (maxy - p[1]) * scale) for p in pts]
202
+ advance = int(round((maxx - minx) * scale)) + 60
203
+ return contour, advance
204
+
205
+
206
+ # ---------- vector capacity (boat + crew) ----------
207
+
208
+ def polygon(pen, pts):
209
+ pen.moveTo(pts[0])
210
+ for p in pts[1:]:
211
+ pen.lineTo(p)
212
+ pen.closePath()
213
+
214
+
215
+ def circle(pen, cx, cy, r, n=40):
216
+ pts = [(cx + r * math.cos(2 * math.pi * i / n), cy + r * math.sin(2 * math.pi * i / n)) for i in range(n)]
217
+ polygon(pen, pts)
218
+
219
+
220
+ def capsule(pen, ax, ay, bx, by, r):
221
+ dx, dy = bx - ax, by - ay
222
+ length = math.hypot(dx, dy) or 1.0
223
+ nx, ny = -dy / length, dx / length
224
+ polygon(pen, [(ax + nx * r, ay + ny * r), (bx + nx * r, by + ny * r), (bx - nx * r, by - ny * r), (ax - nx * r, ay - ny * r)])
225
+ circle(pen, ax, ay, r)
226
+ circle(pen, bx, by, r)
227
+
228
+
229
+ def half_ellipse(pen, cx, cy, rx, ry, n=48):
230
+ pts = [(cx + rx * math.cos(math.pi * i / n), cy - ry * math.sin(math.pi * i / n)) for i in range(n + 1)]
231
+ polygon(pen, pts)
232
+
233
+
234
+ def draw_boat(pen, n, target_h=720):
235
+ """Draw boat + n crew heads, fitting in target_h font units (baseline 0)."""
236
+ s = target_h / 480.0 # design is 480 tall
237
+ half_ellipse(pen, 380 * s, 140 * s, 320 * s, 130 * s)
238
+ capsule(pen, 60 * s, 140 * s, 700 * s, 140 * s, 12 * s)
239
+ capsule(pen, 20 * s, 10 * s, 740 * s, 10 * s, 9 * s)
240
+ if n == 1:
241
+ xs = [380]
242
+ else:
243
+ xs = [round(80 + i * (600 / (n - 1))) for i in range(n)]
244
+ r = 52 if n <= 3 else (44 if n <= 4 else 36)
245
+ for x in xs:
246
+ head_y = 250 + (52 - r)
247
+ circle(pen, x * s, head_y * s, r * s)
248
+ capsule(pen, x * s, (head_y - r + 6) * s, x * s, 150 * s, r * 0.55 * s)
249
+
250
+
251
+ def capacity_glyph(n):
252
+ pen = TTGlyphPen(None)
253
+ draw_boat(pen, n)
254
+ g = pen.glyph()
255
+ xs = [pt[0] for pt in g.coordinates]
256
+ advance = int(round(max(xs))) + 40 if xs else 700
257
+ return g, advance
258
+
259
+
260
+ # ---------- assemble font ----------
261
+
262
+ def main():
263
+ b, W, H = load_binary(SPRITE)
264
+ rows = detect_rows(b, W, H)
265
+ print(f"sprite {W}x{H}, rows: {rows}")
266
+ glyphs = [] # (cp, name, glyph_obj_or_pen_draw, advance, lsb)
267
+
268
+ fb = FontBuilder(UPM, isTTF=True)
269
+ runner_names = [f"runner{i}" for i in range(16)]
270
+ capacity_names = [f"crew{i}" for i in range(6)]
271
+ order = [".notdef"] + runner_names + capacity_names
272
+ cmap = {}
273
+ for i, nm in enumerate(runner_names):
274
+ cmap[0xE700 + i] = nm
275
+ for i, nm in enumerate(capacity_names):
276
+ cmap[0xE710 + i] = nm
277
+ fb.setupGlyphOrder(order)
278
+ fb.setupCharacterMap(cmap)
279
+ fb.setupHorizontalHeader(ascent=ASCENT, descent=0)
280
+
281
+ h_metrics = {".notdef": (UPM, 0)}
282
+ glyf = {}
283
+ nd = TTGlyphPen(None)
284
+ glyf[".notdef"] = nd.glyph()
285
+
286
+ idx = 0
287
+ for (ry0, ry1) in rows:
288
+ comps = connected_components_in_band(b, W, ry0, ry1, min_area=400)
289
+ for (cx0, cy0, cx1, cy1) in comps:
290
+ res = trace_frame(b, cx0, cy0, cx1, cy1)
291
+ if res is None:
292
+ print(f" runner{idx}: empty")
293
+ glyf[f"runner{idx}"] = TTGlyphPen(None).glyph()
294
+ h_metrics[f"runner{idx}"] = (600, 0)
295
+ idx += 1
296
+ continue
297
+ contour, advance = res
298
+ pen = TTGlyphPen(None)
299
+ pen.moveTo(contour[0])
300
+ for p in contour[1:]:
301
+ pen.lineTo(p)
302
+ pen.closePath()
303
+ glyf[f"runner{idx}"] = pen.glyph()
304
+ h_metrics[f"runner{idx}"] = (advance, 0)
305
+ print(f" runner{idx}: {len(contour)} pts, advance {advance}")
306
+ idx += 1
307
+ if idx < 16:
308
+ for k in range(idx, 16):
309
+ glyf[f"runner{k}"] = TTGlyphPen(None).glyph()
310
+ h_metrics[f"runner{k}"] = (600, 0)
311
+
312
+ for i in range(6):
313
+ g, advance = capacity_glyph(i + 1)
314
+ glyf[f"crew{i}"] = g
315
+ h_metrics[f"crew{i}"] = (advance, 0)
316
+ print(f" crew{i}: advance {advance}")
317
+
318
+ fb.setupHorizontalMetrics(h_metrics)
319
+ fb.setupGlyf(glyf)
320
+ fb.setupNameTable({"familyName": "Crew Vibes", "styleName": "Regular"})
321
+ fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=0, sTypoLineGap=0)
322
+ fb.setupPost()
323
+ fb.font.save(TTF_OUT)
324
+ print(f"Wrote {TTF_OUT} ({os.path.getsize(TTF_OUT)} bytes)")
325
+
326
+
327
+ if __name__ == "__main__":
328
+ main()