simframe 0.10.0 → 0.12.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.
Files changed (47) hide show
  1. package/README.md +181 -2
  2. package/data/vocabulary/en.json +148 -0
  3. package/native/ocr.swift +13 -1
  4. package/native/rank.swift +87 -0
  5. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +72 -4
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
  8. package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +19 -0
  9. package/native/simframed/Sources/simframed/main.swift +33 -3
  10. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +41 -0
  11. package/native/supervise.swift +216 -0
  12. package/package.json +4 -1
  13. package/scripts/check-package.mjs +22 -2
  14. package/scripts/check-private.mjs +9 -0
  15. package/scripts/ci-integration-local.sh +79 -0
  16. package/scripts/ci-memory.mjs +104 -20
  17. package/scripts/collect-rulings.mjs +312 -0
  18. package/scripts/eval-fingerprint.mjs +100 -23
  19. package/scripts/eval-perception.mjs +33 -0
  20. package/scripts/phase17-corpus.mjs +176 -0
  21. package/scripts/probe-network.mjs +118 -0
  22. package/scripts/soak-capture.mjs +72 -0
  23. package/skills/simframe/SKILL.md +257 -7
  24. package/src/actions.js +1791 -44
  25. package/src/cli.js +216 -14
  26. package/src/control.js +1 -0
  27. package/src/fingerprint.js +43 -1
  28. package/src/graph.js +136 -7
  29. package/src/index.js +252 -14
  30. package/src/input.js +66 -3
  31. package/src/localhelper.js +161 -0
  32. package/src/matching.js +64 -1
  33. package/src/mcp.js +333 -32
  34. package/src/metrics.js +148 -3
  35. package/src/ocr.js +18 -1
  36. package/src/planner.js +195 -0
  37. package/src/platform/android.js +25 -1
  38. package/src/platform/index.js +11 -1
  39. package/src/platform/ios.js +25 -1
  40. package/src/png.js +26 -0
  41. package/src/refs.js +51 -8
  42. package/src/regions.js +215 -1
  43. package/src/screenmap.js +89 -9
  44. package/src/supervisor.js +161 -0
  45. package/src/view.js +375 -11
  46. package/src/vocabulary.js +134 -0
  47. package/src/wrote.js +136 -0
@@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url';
36
36
  import * as fingerprint from '../src/fingerprint.js';
37
37
  import * as matching from '../src/matching.js';
38
38
  import * as analyze from '../src/analyze.js';
39
+ import * as view from '../src/view.js';
39
40
 
40
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
42
  const DIR = path.join(ROOT, 'test', 'perception', 'screens');
@@ -68,6 +69,7 @@ export function checkScreen(fx) {
68
69
  const authored = (expect.resolutions?.length ?? 0)
69
70
  + (expect.ambiguous?.length ?? 0)
70
71
  + (expect.none?.length ?? 0)
72
+ + (expect.discoverable?.length ?? 0)
71
73
  + (fx.frame_pairs?.length ?? 0)
72
74
  + (expect.identity ? 1 : 0);
73
75
 
@@ -118,6 +120,37 @@ export function checkScreen(fx) {
118
120
  }
119
121
  }
120
122
 
123
+ // Discovery: does the default map still SAY what is on this screen?
124
+ //
125
+ // This check exists because of a regression it would have caught. A rule that
126
+ // dropped long non-interactive rows left every React Native list card out of
127
+ // the map — the cards expose their children as one concatenated accessibility
128
+ // label, so they look exactly like a Settings caption. Nothing became
129
+ // untappable, because `locate` reads targets rather than rows, so every
130
+ // resolution expectation above still passed. What was lost was the map's
131
+ // account of what is there, and an agent that cannot see a row falls back to
132
+ // a ~1600-token screenshot.
133
+ //
134
+ // Resolution and discovery are different claims, and the harness could only
135
+ // make the first one.
136
+ if (expect.discoverable?.length && screen) {
137
+ const { rows } = view.rowsFor({ targets }, { screen });
138
+ const shown = rows.map((r) => String(r.label ?? ''));
139
+ for (const want of expect.discoverable) {
140
+ // Compared as a prefix, because a long label is legitimately truncated in
141
+ // a row — truncated is discoverable, absent is not.
142
+ const head = want.slice(0, 24);
143
+ if (!shown.some((got) => got.startsWith(head))) {
144
+ findings.push({
145
+ kind: 'discovery',
146
+ query: `${want.slice(0, 40)}${want.length > 40 ? '…' : ''}`,
147
+ want: 'listed in the default map',
148
+ got: `${rows.length} row(s), none starting with it`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+
121
154
  // Identity drift. A token-rule change that silently alters a screen's
122
155
  // structural hash discards every stored map and graph for it, and the failure
123
156
  // is invisible — an old hash is a well-formed hash that matches nothing.
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Phase 17, go/no-go step 1: build the corpus and ask whether there is a job.
4
+ *
5
+ * The plan in docs/PHASES-HUMAN-PARITY.md said to export 200 `ambiguous_intent`
6
+ * and `no_plan` escalations. That corpus cannot be assembled: `no_plan` has
7
+ * never been logged once, `ambiguous_intent` is the reason the ranking bug
8
+ * produced (so the pre-fix records encode a bug), and the session id is minted
9
+ * per process — so a CLI-driven agent gets one "session" per command and the
10
+ * "filter to one session id" step has nothing to filter.
11
+ *
12
+ * There is a better corpus and it was there all along. Every verified graph edge
13
+ * is a decision that *worked*: the goal is in `step`, the screen it was taken on
14
+ * is the node, and the element list for that screen is in the screen map. That
15
+ * is (goal, element list, action) for every successful step, not just the rare
16
+ * failures — and the ground truth is stronger, because the tap was verified.
17
+ *
18
+ * What this measures is the question the whole phase turns on: of the decisions
19
+ * an agent actually makes, how many does the local matcher already resolve? A
20
+ * local planner can only earn its keep on the remainder. If the remainder is
21
+ * small, Phase 17 is a no-go regardless of how good the model is.
22
+ *
23
+ * Output is aggregate by design. This reads a real device's memory of real
24
+ * third-party apps, so it prints counts and never a label, an app name or a
25
+ * screen's contents. See the standing rule in scripts/check-private.mjs.
26
+ *
27
+ * Usage: node scripts/phase17-corpus.mjs [--device <udid>] [--json]
28
+ */
29
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { homedir } from 'node:os';
32
+ import * as matching from '../src/matching.js';
33
+ import { goalOf } from '../src/actions.js';
34
+
35
+ const ROOT = process.env.SIMFRAME_HOME || join(homedir(), '.simframe');
36
+
37
+ const readJson = (p) => {
38
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
39
+ };
40
+ const listDir = (p) => (existsSync(p) ? readdirSync(p).filter((f) => f.endsWith('.json')) : []);
41
+
42
+ /** Screens are keyed by frame hash; graph nodes carry a layoutHash. Index both. */
43
+ function screenIndex(dev) {
44
+ const idx = new Map();
45
+ for (const f of listDir(join(dev, 'screens'))) {
46
+ const s = readJson(join(dev, 'screens', f));
47
+ if (!s?.targets?.length) continue;
48
+ for (const k of ['layoutHash', 'structuralHash', 'hash']) {
49
+ if (s[k] && !idx.has(s[k])) idx.set(s[k], s);
50
+ }
51
+ }
52
+ return idx;
53
+ }
54
+
55
+ /**
56
+ * Only some steps involve choosing an element. `launch` names an app, `button`
57
+ * names hardware, `openUrl` names a URL, `scroll` names a direction — none of
58
+ * them is a decision a planner could help with, and counting them was inflating
59
+ * the "not found" bucket to 45% on the first run of this script.
60
+ */
61
+ const CHOOSES_AN_ELEMENT = new Set(['tap', 'type', 'scroll_to', 'scrollTo', 'assert', 'swipe_from', 'longPress']);
62
+
63
+ /** A "#3" is answered by the ref table and a "@x,y" by arithmetic. Neither is a decision. */
64
+ const isSelectorNotIntent = (goal) => /^#\d+$/.test(goal) || /^@?-?\d+\s*,\s*-?\d+$/.test(goal);
65
+
66
+ export function classify(dev) {
67
+ const idx = screenIndex(dev);
68
+ const out = {
69
+ screens_stored: idx.size,
70
+ edges: 0,
71
+ unjoinable: 0,
72
+ no_goal: 0,
73
+ not_an_element_step: 0,
74
+ selector_not_intent: 0,
75
+ cases: 0,
76
+ resolved: 0,
77
+ ambiguous: 0,
78
+ none: 0,
79
+ };
80
+ for (const f of listDir(join(dev, 'graph'))) {
81
+ const g = readJson(join(dev, 'graph', f));
82
+ const screen = idx.get(g?.layoutHash) ?? idx.get(g?.hash);
83
+ for (const e of g?.edges ?? []) {
84
+ out.edges += 1;
85
+ if (!screen) { out.unjoinable += 1; continue; }
86
+ const goal = goalOf(e.step);
87
+ if (!goal) { out.no_goal += 1; continue; }
88
+ if (!CHOOSES_AN_ELEMENT.has(e.step?.action)) { out.not_an_element_step += 1; continue; }
89
+ if (isSelectorNotIntent(String(goal).trim())) { out.selector_not_intent += 1; continue; }
90
+ out.cases += 1;
91
+ const r = matching.resolve(screen.targets, String(goal));
92
+ if (r.status === 'ok') out.resolved += 1;
93
+ else if (r.status === 'ambiguous') out.ambiguous += 1;
94
+ else out.none += 1;
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * The second instrument, with the opposite bias.
102
+ *
103
+ * A verified edge is a decision that *succeeded*, so the graph cannot see the
104
+ * ones the matcher fumbled — those became escalations, Claude fixed them, and
105
+ * the edge was written with the corrected goal. Measuring the matcher on its
106
+ * own successes is partly circular. So also count resolution failures from the
107
+ * log against total edge traversals: same question, denominator built from
108
+ * failures instead of successes.
109
+ */
110
+ export function resolutionFailureRate(dev) {
111
+ let traversals = 0;
112
+ for (const f of listDir(join(dev, 'graph'))) {
113
+ for (const e of readJson(join(dev, 'graph', f))?.edges ?? []) traversals += e.count ?? 0;
114
+ }
115
+ let failures = 0;
116
+ let escalations = 0;
117
+ const log = join(dev, 'escalations.jsonl');
118
+ if (existsSync(log)) {
119
+ for (const line of readFileSync(log, 'utf8').split('\n')) {
120
+ if (!line.trim()) continue;
121
+ let r;
122
+ try { r = JSON.parse(line); } catch { continue; }
123
+ escalations += 1;
124
+ if (r.reason === 'ambiguous_intent' || r.reason === 'unknown_screen') failures += 1;
125
+ }
126
+ }
127
+ return { traversals, escalations, failures };
128
+ }
129
+
130
+ const flags = process.argv.slice(2);
131
+ const only = flags.includes('--device') ? flags[flags.indexOf('--device') + 1] : null;
132
+ const devices = (existsSync(ROOT) ? readdirSync(ROOT) : [])
133
+ .filter((d) => !d.startsWith('TEST-') && d !== 'bin')
134
+ .filter((d) => existsSync(join(ROOT, d, 'graph')))
135
+ .filter((d) => !only || d === only);
136
+
137
+ const totals = { cases: 0, resolved: 0, ambiguous: 0, none: 0, edges: 0, unjoinable: 0, not_an_element_step: 0, selector_not_intent: 0 };
138
+ const rows = [];
139
+ for (const d of devices) {
140
+ const r = classify(join(ROOT, d));
141
+ if (!r.edges) continue;
142
+ rows.push({ device: `${d.slice(0, 8)}…`, ...r, ...resolutionFailureRate(join(ROOT, d)) });
143
+ for (const k of Object.keys(totals)) totals[k] += r[k];
144
+ }
145
+
146
+ if (flags.includes('--json')) {
147
+ console.log(JSON.stringify({ devices: rows, totals }, null, 2));
148
+ } else {
149
+ console.log('Phase 17 corpus — verified graph edges as (goal, element list, action)\n');
150
+ for (const r of rows) {
151
+ console.log(` ${r.device} edges ${r.edges} joinable ${r.edges - r.unjoinable} decisions ${r.cases}` +
152
+ ` → matcher ok ${r.resolved}, ambiguous ${r.ambiguous}, not found ${r.none}`);
153
+ }
154
+ const { cases, resolved, ambiguous, none } = totals;
155
+ const pct = (n) => (cases ? `${Math.round((n / cases) * 1000) / 10}%` : '—');
156
+ console.log(`\n ${totals.edges} edges: ${totals.unjoinable} unjoinable, ` +
157
+ `${totals.not_an_element_step} chose no element (launch/button/openUrl/scroll), ` +
158
+ `${totals.selector_not_intent} used a #ref or a coordinate.`);
159
+ console.log(` TOTAL element decisions: ${cases}`);
160
+ console.log(` already resolved locally ${resolved} ${pct(resolved)} <- a planner adds nothing here`);
161
+ console.log(` ambiguous ${ambiguous} ${pct(ambiguous)} <- a planner could pick`);
162
+ console.log(` not found ${none} ${pct(none)} <- a planner cannot invent an element`);
163
+ const addressable = ambiguous;
164
+ console.log(`\n Addressable by a local planner: ${addressable} of ${cases} (${pct(addressable)}).`);
165
+
166
+ console.log('\nSecond instrument — resolution failures from the log, per traversal.');
167
+ console.log('A rate over 100% means the graph was discarded while the log kept appending');
168
+ console.log('(a MAP_VERSION or GRAPH_VERSION bump), so it is not a rate. Read the device');
169
+ console.log('that was driven by an agent doing real work, not the bench device.\n');
170
+ for (const r of rows) {
171
+ const rate = r.traversals ? `${Math.round((r.failures / r.traversals) * 1000) / 10}%` : '—';
172
+ console.log(` ${r.device} traversals ${String(r.traversals).padStart(4)}` +
173
+ ` escalations ${String(r.escalations).padStart(4)}` +
174
+ ` resolution failures ${String(r.failures).padStart(3)} ${rate.padStart(6)}`);
175
+ }
176
+ }
@@ -0,0 +1,118 @@
1
+ // Minimal CDP client over a hand-rolled WebSocket — no dependencies, because
2
+ // simframe has exactly one runtime dependency and this would not be it.
3
+ //
4
+ // Node 20 has no global WebSocket, so the upgrade and the framing are done by
5
+ // hand. Item 80 records that the upgrade 401s without an `Origin` matching the
6
+ // inspector's host; this sends one.
7
+ import http from 'node:http';
8
+ import crypto from 'node:crypto';
9
+
10
+ const wsUrl = process.argv[2];
11
+ const seconds = Number(process.argv[3] ?? 20);
12
+ const u = new URL(wsUrl);
13
+
14
+ const key = crypto.randomBytes(16).toString('base64');
15
+ const req = http.request({
16
+ hostname: u.hostname,
17
+ port: u.port,
18
+ path: u.pathname + u.search,
19
+ headers: {
20
+ Connection: 'Upgrade',
21
+ Upgrade: 'websocket',
22
+ 'Sec-WebSocket-Key': key,
23
+ 'Sec-WebSocket-Version': '13',
24
+ Origin: `http://${u.host}`,
25
+ },
26
+ });
27
+
28
+ /** A client frame must be masked; the server's are not. */
29
+ const frame = (text) => {
30
+ const payload = Buffer.from(text, 'utf8');
31
+ const mask = crypto.randomBytes(4);
32
+ const head = payload.length < 126
33
+ ? Buffer.from([0x81, 0x80 | payload.length])
34
+ : Buffer.concat([Buffer.from([0x81, 0xfe]), (() => { const b = Buffer.alloc(2); b.writeUInt16BE(payload.length); return b; })()]);
35
+ const masked = Buffer.from(payload);
36
+ for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
37
+ return Buffer.concat([head, mask, masked]);
38
+ };
39
+
40
+ req.on('upgrade', (res, socket) => {
41
+ console.log(`upgraded: ${res.statusCode} ${res.headers.upgrade ?? ''}`);
42
+ let buf = Buffer.alloc(0);
43
+ let id = 0;
44
+ const send = (method, params) => {
45
+ id += 1;
46
+ socket.write(frame(JSON.stringify({ id, method, params })));
47
+ console.log(`-> ${method}`);
48
+ return id;
49
+ };
50
+
51
+ const seen = new Map();
52
+ socket.on('data', (chunk) => {
53
+ buf = Buffer.concat([buf, chunk]);
54
+ // Enough framing to read small unfragmented text frames, which is all CDP
55
+ // sends here. Deliberately not a WebSocket implementation.
56
+ for (;;) {
57
+ if (buf.length < 2) return;
58
+ const len0 = buf[1] & 0x7f;
59
+ let offset = 2;
60
+ let len = len0;
61
+ if (len0 === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); offset = 4; }
62
+ else if (len0 === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); offset = 10; }
63
+ if (buf.length < offset + len) return;
64
+ const text = buf.slice(offset, offset + len).toString('utf8');
65
+ buf = buf.slice(offset + len);
66
+ try {
67
+ const msg = JSON.parse(text);
68
+ if (msg.method) {
69
+ seen.set(msg.method, (seen.get(msg.method) ?? 0) + 1);
70
+ // The contents are the whole question: "settled, and these three
71
+ // requests fired with these statuses" is only sayable if the status
72
+ // and the URL are actually in here.
73
+ if (msg.method.startsWith('Network.')) {
74
+ const p = msg.params ?? {};
75
+ const bits = [
76
+ p.requestId && `id=${p.requestId}`,
77
+ p.request?.method && `${p.request.method} ${p.request.url}`,
78
+ p.response && `status=${p.response.status} ${p.response.mimeType ?? ''}`,
79
+ p.response?.timing && 'timing=yes',
80
+ p.encodedDataLength != null && `bytes=${p.encodedDataLength}`,
81
+ p.type && `type=${p.type}`,
82
+ ].filter(Boolean);
83
+ console.log(` ${msg.method}: ${bits.join(' ')}`);
84
+ }
85
+ }
86
+ else console.log(`<- reply #${msg.id} ${msg.error ? `ERROR ${JSON.stringify(msg.error)}` : JSON.stringify(msg.result).slice(0, 120)}`);
87
+ } catch { /* not JSON; ignore */ }
88
+ }
89
+ });
90
+
91
+ send('Network.enable', {});
92
+ send('Runtime.enable', {});
93
+ send('Log.enable', {});
94
+
95
+ // Trigger real traffic through the app's own JS runtime, rather than through
96
+ // its UI. This isolates the question — does React Native's networking layer
97
+ // report to CDP at all — from whether my app happens to call anything, and it
98
+ // needs no screen, so it does not fight whatever else is driving the device.
99
+ setTimeout(() => {
100
+ send('Runtime.evaluate', {
101
+ expression: "fetch('https://jsonplaceholder.typicode.com/todos/1')"
102
+ + ".then(r => r.json()).then(j => 'FETCHED ' + JSON.stringify(j))",
103
+ awaitPromise: true,
104
+ returnByValue: true,
105
+ });
106
+ }, 1500);
107
+
108
+ setTimeout(() => {
109
+ console.log('\nevents received, by method:');
110
+ if (!seen.size) console.log(' (none)');
111
+ for (const [m, n] of [...seen].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)}x ${m}`);
112
+ socket.destroy();
113
+ process.exit(0);
114
+ }, seconds * 1000);
115
+ });
116
+ req.on('response', (res) => { console.log(`NOT upgraded: ${res.statusCode}`); process.exit(1); });
117
+ req.on('error', (e) => { console.log('error:', e.message); process.exit(1); });
118
+ req.end();
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // How long can this device be driven before capture wedges?
3
+ //
4
+ // The wedge is the thing standing between us and a ruling population: the
5
+ // display stops rendering after a few minutes of hard driving, `simctl
6
+ // screenshot` fails too, both of the daemon's recoveries fail, and only a
7
+ // device restart cures it. Three times in one afternoon.
8
+ //
9
+ // A leak was found in our own code on 2026-09-12 — damage-callback registration
10
+ // was not idempotent, and the recovery loop called it on every attempt, so one
11
+ // log's 670 port re-resolves meant up to 670 live callbacks on one port, each
12
+ // invoked per redraw. That is a real leak with a plausible path to saturating
13
+ // the display service, and it is **not proof** that it is the cause. This is
14
+ // how we find out: drive until it dies, and report how long that took.
15
+ //
16
+ // node scripts/soak-capture.mjs --device=<udid> --minutes=25
17
+ //
18
+ // A number to compare against, not a pass/fail. Before the fix, the device
19
+ // wedged roughly every 10-20 minutes of this kind of work.
20
+ import * as actions from '../src/actions.js';
21
+ import * as api from '../src/index.js';
22
+ import * as store from '../src/store.js';
23
+
24
+ const arg = (n, d) => {
25
+ const hit = process.argv.find((a) => a.startsWith(`--${n}=`));
26
+ return hit ? hit.slice(n.length + 3) : d;
27
+ };
28
+ const device = arg('device');
29
+ const minutes = Number(arg('minutes', 20));
30
+ const BUNDLE = 'com.example.simframetestbed';
31
+
32
+ const { device: dev } = await api.ensureDaemon(device);
33
+ console.log(`device: ${dev.name} (${dev.runtime}) budget: ${minutes} minutes`);
34
+
35
+ const started = Date.now();
36
+ const deadline = started + minutes * 60_000;
37
+ let laps = 0;
38
+ let reads = 0;
39
+ const elapsed = () => ((Date.now() - started) / 60_000).toFixed(1);
40
+
41
+ // A lap is deliberately the kind of work that provokes it: launching, moving
42
+ // between screens, and a cold read on each — not idling with a poll.
43
+ const LAP = [
44
+ [{ launch: { value: BUNDLE, relaunch: true } }, { pause: 2500 }],
45
+ [{ tap: 'Forms, tab, 2 of 3' }, { pause: 800 }],
46
+ [{ tap: 'Long form' }, { pause: 900 }],
47
+ [{ scroll: 'down' }, { pause: 500 }],
48
+ [{ scroll: 'down' }, { pause: 500 }],
49
+ [{ tap: 'Plants, tab, 1 of 3' }, { pause: 900 }],
50
+ [{ tap: 'Diagnostics, tab, 3 of 3' }, { pause: 900 }],
51
+ ];
52
+
53
+ while (Date.now() < deadline) {
54
+ for (const steps of LAP) {
55
+ try {
56
+ await actions.runScript(device, { steps, verify: false, options: { supervisor: 'none' } });
57
+ } catch { /* a failed step is not the subject; a dead display is */ }
58
+ try {
59
+ await api.screenIdentity(device, { fresh: true, confirmNovel: false });
60
+ reads += 1;
61
+ } catch (err) {
62
+ console.log(`\nWEDGED after ${elapsed()} minutes, ${laps} laps, ${reads} cold reads`);
63
+ console.log(` ${String(err.message).split('\n')[0]}`);
64
+ const health = store.captureHealth(dev.udid);
65
+ if (health) console.log(` captureHealth: ${JSON.stringify(health)}`);
66
+ process.exit(1);
67
+ }
68
+ }
69
+ laps += 1;
70
+ if (laps % 5 === 0) process.stdout.write(` ${elapsed()}m ${laps} laps ${reads} reads still alive\n`);
71
+ }
72
+ console.log(`\nSURVIVED ${minutes} minutes: ${laps} laps, ${reads} cold reads, no wedge`);