diff --git a/README.md b/README.md
index c5a322d..deeeb65 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,14 @@ per extra fragment. You can put things down again from the traceroute map. That'
the whole economy: everything worth reading costs you some of the distance you have
left.
+## Retrain
+
+The line doesn't stay the way you negotiated it. Somewhere after hop 51, wherever you
+happen to be, it degrades and takes a baud rate off you — and the way to claw it back
+is the ANSam beat from hop 47: `SPACE` on the phase reversal, 450 ms, four clean ones.
+Terminal levels opt out with `noRetrain: true`, because stealing `SPACE` from someone
+mid-sentence isn't a mechanic, it's a bug with a story attached.
+
## Audio
[`src/audio.js`](src/audio.js) — every tone is oscillators, nothing is sampled, and
@@ -117,5 +125,4 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee
- **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it.
- **The 200 philes** — 74 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 126 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found.
- **Era-generated music** — the design doc says each level's pad is built from that era's protocol. Right now they're chords.
-- **The retrain** — a mid-game renegotiation that drops your baud without warning. One system, touches every level after it.
- Levels run 3–8 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next.
diff --git a/STAGES.md b/STAGES.md
index d2f2bf7..8871947 100644
--- a/STAGES.md
+++ b/STAGES.md
@@ -358,8 +358,8 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
| | |
|---|---|
-| **The philes** | 73 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
+| **The philes** | 75 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
| **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. |
-| **The retrain** | A mid-game renegotiation that drops your baud without warning. One system, touches every level after it. |
+| ~~**The retrain**~~ | **DONE** — `src/retrain.js`. Fires once per run after hop 51, drops you a notch, and the recovery is the 450 ms ANSam beat from hop 47. Terminal levels opt out. |
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
| **The 64 hops** | 13 levels covering 64 hop numbers. Either fill in more levels or make the existing ones properly own their ranges. |
diff --git a/src/levels/board.js b/src/levels/board.js
index 9ace14e..bb2060a 100644
--- a/src/levels/board.js
+++ b/src/levels/board.js
@@ -172,7 +172,7 @@ const FILES = [
export default {
id: 'board', hop: 35, title: 'THE BOARD', via: 'sysop.2400.8n1', ms: '2400 ms',
- baud: null, // whatever you earned at the handshake. that is the point.
+ baud: null, noRetrain: true, // whatever you earned at the handshake. that is the point.
async mount(ctx) {
S = { t: 0, busyTries: 0, cargo: 0, uploads: 0, nup: false, elite: false, bombed: false, done: false,
diff --git a/src/levels/cuckoo.js b/src/levels/cuckoo.js
index a0fb45c..ed78474 100644
--- a/src/levels/cuckoo.js
+++ b/src/levels/cuckoo.js
@@ -54,7 +54,7 @@ let S = {};
export default {
id: 'cuckoo', hop: 29, title: "THE CUCKOO'S EGG", via: 'lbl.berkeley.1986', ms: '75 ms',
- baud: 1,
+ baud: 1, noRetrain: true,
async mount(ctx) {
S = { t: 0, lines: 50, read: 0, cwd: '/', gone: false, caught: false, elapsed: 0 };
diff --git a/src/levels/shell.js b/src/levels/shell.js
index 1f19302..25c3885 100644
--- a/src/levels/shell.js
+++ b/src/levels/shell.js
@@ -90,6 +90,21 @@ const TALK_RMS = [
'you may go back to what you were doing. i am aware of how i come across.',
];
+// He does not get angrier. He gets tireder. And at the end he stops trying to
+// change your mind and changes the software instead, which is entirely in character
+// and is, in fairness, the whole strategy that worked.
+const UNAME_AGAIN = [
+ [ "i'd just like to interject for a moment.",
+ 'you have just done it again. what you are referring to as linux is in',
+ 'fact GNU/linux. i will not labour the point. i have laboured it.' ],
+ [ "i'd just like to interject.",
+ 'GNU/linux. the kernel is the part called linux. the rest of it is the',
+ 'part i spent eight years on.' ],
+ [ 'GNU/linux.' ],
+ [ '...' ],
+ [ 'no. i am not doing this again.' ],
+];
+
const TALK = [
['minna', 'you are new'],
['you', ''],
@@ -129,11 +144,11 @@ const BUILD = [
export default {
id: 'shell', hop: 43, title: 'THE SHELL', via: 'kruuna.helsinki.fi', ms: '31 ms',
- baud: null,
+ baud: null, noRetrain: true,
async mount(ctx) {
S = { t: 0, done: false, built: false, building: false, buildAt: 0, talked: false, read: false,
- rmsWaiting: false, metRms: false };
+ rmsWaiting: false, metRms: false, unameCount: 0, unamePatched: false, notedPatch: false };
window.__S = S; // debug handle
ctx.scene.background = new THREE.Color(0x000000);
@@ -165,7 +180,7 @@ export default {
await ctx.sleep(900);
ctx.phile('always-on');
- ctx.hint('who news talk make logout');
+ ctx.hint('who news talk make uname logout');
ctx.say('type help. it is a real shell and it is on all the time.', 5000);
for (;;) {
@@ -178,7 +193,7 @@ export default {
if (!cmd) continue;
if (cmd === 'help') {
- term.writeln('|08 who news talk minna make uptime logout');
+ term.writeln('|08 who news talk minna make uname uptime logout');
term.writeln('|08 (and it will keep compiling while you do all of those.)');
} else if (cmd === 'who') {
WHO.forEach(([u, tty, at, from]) =>
@@ -187,6 +202,21 @@ export default {
term.writeln('|03 thirty-eight logged in. the board had forty-four users in nine years');
term.writeln('|03 and could hold exactly one of them at a time.');
ctx.phile('multiuser');
+ } else if (cmd === 'uname') {
+ if (S.unamePatched) {
+ term.writeln(arg === '-a'
+ ? '|11 GNU/Linux kruuna 0.12 #1 Sat Jan 4 12:33:16 EET 1992 i386 GNU/Linux'
+ : '|11 GNU/Linux');
+ if (!S.notedPatch) {
+ S.notedPatch = true;
+ term.writeln('|08 (it has said that since he rebuilt it. he did not ask anybody.)');
+ }
+ } else {
+ term.writeln(arg === '-a'
+ ? '|11 Linux kruuna 0.12 #1 Sat Jan 4 12:33:16 EET 1992 i386'
+ : '|11 Linux');
+ await this.interject(ctx, term);
+ }
} else if (cmd === 'uptime') {
term.writeln('|11 11:34am up 91 days, 3:12, 38 users, load average: 2.14, 1.88, 1.51');
term.writeln('|08 ninety-one days. it has not been switched off since february.');
@@ -278,6 +308,44 @@ export default {
ctx.say('he is right, and it took him nine years of saying it to be heard once.', 6000);
},
+ // ── each one shorter and flatter than the last, until he gives up on you and
+ // goes and edits the program instead.
+ async interject(ctx, term) {
+ if (!S.metRms) return; // he has to have introduced himself first
+ const n = S.unameCount || 0;
+ if (n >= UNAME_AGAIN.length) return;
+ S.unameCount = n + 1;
+ const lines = UNAME_AGAIN[n];
+ await ctx.sleep(700);
+ term.writeln('');
+ term.writeln('|14Message from rms@gnu.ai.mit.edu on ttyp7 ...');
+ for (const line of lines) {
+ term.write('|14 ');
+ for (const ch of line) {
+ term.write(ch);
+ if (ch !== ' ') A.blip(1400 + Math.random() * 200, 0.004, 0.010);
+ // he types faster and flatter every time
+ await ctx.sleep(Math.max(6, 20 - n * 4));
+ }
+ term.writeln('');
+ await ctx.sleep(200);
+ }
+
+ if (S.unameCount >= UNAME_AGAIN.length) {
+ // he stops trying to change your mind and changes the program. which is, in
+ // fairness, the strategy that actually worked.
+ await ctx.sleep(900);
+ term.writeln('');
+ await term.paint('|08 rms@ttyp7 is running: cd /usr/src/gnu/uname && make install\n', 1600);
+ await ctx.sleep(900);
+ await term.paint('|10 [rms has rebuilt uname. he did not ask anybody.]\n', 1400);
+ S.unamePatched = true;
+ A.burst(1320, 1760, 0.26, 0.16);
+ ctx.phile('uname');
+ ctx.say('he stopped arguing with you and edited the program. that is the entire
strategy, and it is the one that worked.', 6400);
+ }
+ },
+
async logout(ctx, term) {
S.done = true;
In.releaseText();
diff --git a/src/main.js b/src/main.js
index 9077328..e8a67e2 100644
--- a/src/main.js
+++ b/src/main.js
@@ -7,6 +7,7 @@ import * as HUD from './hud.js';
import { Link } from './link.js';
import { World } from './kit.js';
import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js';
+import * as Retrain from './retrain.js';
import coldboot from './levels/coldboot.js';
import stack from './levels/stack.js';
@@ -194,6 +195,7 @@ export async function goto(id, { restart = false } = {}) {
const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })();
HUD.veil(0, 520);
await mounting;
+ Retrain.arm(ctx, nextLevel);
refreshHud();
saveGame();
if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; }
@@ -218,6 +220,8 @@ export function step(dt) {
if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader();
if (In.hit('Backquote')) toggleDev();
+ if (!game.paused) Retrain.update(dt, ctx);
+
if (!game.paused && game.level && game.level.update) {
try { game.level.update(dt, game.time, ctx); } catch (e) { console.error(e); game.level.update = null; }
}
diff --git a/src/philes.js b/src/philes.js
index 952adc9..a457f97 100644
--- a/src/philes.js
+++ b/src/philes.js
@@ -725,6 +725,24 @@ he has been on ttyp7 since six in the morning. he saw who(1) go past. he
waited until you called it an operating system.`,
},
+ 'uname': {
+ title: 'he rebuilt uname',
+ from: 'ttyp7, having given up on you personally',
+ body: `five times you ran it and five times it said Linux and five times he said
+something, each one shorter and flatter than the last, until he stopped.
+
+then he went and edited the program so that it says GNU/Linux, and
+installed it, and did not ask anybody.
+
+which is, in fairness, the entire strategy. he did not win the argument
+about naming and he was never going to. what he did instead was write a
+compiler, a linker, a shell, a libc, a debugger and a text editor, put all
+of them under a licence that forces everyone downstream to keep them free,
+and wait.
+
+that part worked completely. you are reading this on it.`,
+ },
+
// ── hop 45–50, the handshake ───────────────────────────────────────────
'dtmf': {
@@ -773,6 +791,21 @@ different speeds, and why the number it lands on is not a specification.
it is a measurement of the copper between you.`,
},
+ 'retrain': {
+ title: 'retrain',
+ from: 'the line, changing its mind',
+ body: `it did not stay the way you negotiated it.
+
+the copper got wet, or somebody in the house picked up an extension, or
+the exchange quietly rerouted you through a worse pair at three in the
+morning — and the two modems stopped in the middle of whatever you were
+doing and did the entire handshake again from the top.
+
+sometimes you came back faster. usually you did not. and the number it
+landed on the second time was the number you lived with, because nobody
+was going to hang up and try again at those prices.`,
+ },
+
'connect': {
title: 'CONNECT 14400/ARQ/V32B/LAPM/V42BIS',
from: 'the line that told you what you got',
diff --git a/src/retrain.js b/src/retrain.js
new file mode 100644
index 0000000..2abee15
--- /dev/null
+++ b/src/retrain.js
@@ -0,0 +1,125 @@
+// retrain.js — the line does not stay the way you negotiated it.
+//
+// Real modems renegotiated mid-call. The copper got wet, somebody picked up an
+// extension, the exchange rerouted you through a worse pair, and the two ends
+// stopped and did the whole handshake again in the middle of your download.
+//
+// So: at some point after the handshake, wherever you happen to be, the line
+// degrades and takes a baud rate off you. You get one chance to claw it back, and
+// the chance is the ANSam beat you learned at hop 47 — press on the phase reversal,
+// four times, at 450 ms, which is a real interval you have already been taught.
+
+import * as A from './audio.js';
+import * as In from './input.js';
+import * as HUD from './hud.js';
+
+const NEED = 4; // clean reversals to win the line back
+const WINDOW = 0.20; // how close to the reversal you have to be
+
+let R = {
+ armed: false, live: false, t: 0, cool: 0,
+ hits: 0, misses: 0, ans: null, sinceFlip: 0, phase: 1, done: false,
+};
+
+export function reset() {
+ if (R.ans) { R.ans.stop(); R.ans = null; }
+ R = { armed: false, live: false, t: 0, cool: 0, hits: 0, misses: 0,
+ ans: null, sinceFlip: 0, phase: 1, done: false };
+}
+
+// A level says whether it can survive being interrupted. Terminal levels cannot —
+// SPACE is a character there, and stealing it mid-sentence is not a mechanic, it is
+// a bug with a story attached.
+export function arm(ctx, level) {
+ reset();
+ if (!level || level.noRetrain) return;
+ if (ctx.game.flags.retrained) return; // once a run. it is a beat, not a tax.
+ if (ctx.link.index <= 1) return; // nothing left to take
+ if (level.hop < 51) return; // only after you have earned a rate
+ R.armed = true;
+ R.cool = 22 + Math.random() * 40; // somewhere in the middle of the level
+}
+
+export function update(dt, ctx) {
+ if (!R.armed || R.done) return false;
+ R.t += dt;
+
+ if (!R.live) {
+ R.cool -= dt;
+ if (R.cool > 0) return false;
+ return begin(ctx);
+ }
+
+ // ── the ANSam beat. 450 ms, exactly as it was at hop 47.
+ R.sinceFlip += dt;
+ if (R.sinceFlip >= 0.45) {
+ R.sinceFlip -= 0.45;
+ R.phase = -R.phase;
+ R.flipAt = R.t;
+ R.counted = false;
+ R.flash = 0.42;
+ A.relayClick(0.13, 0.6); // the tick you are aiming at
+ }
+ R.flash = Math.max(0.14, (R.flash || 0.14) - dt * 3.1);
+ ctx.link.glitch = R.flash;
+
+ if (In.jumpHit()) {
+ const off = Math.abs(R.t - (R.flipAt ?? -9));
+ if (off < WINDOW && !R.counted) {
+ R.counted = true;
+ R.hits++;
+ A.burst(2100, 1050, 0.10, 0.16);
+ HUD.say(`retrain ${R.hits}/${NEED}`, 900);
+ if (R.hits >= NEED) return win(ctx);
+ } else {
+ R.misses++;
+ R.hits = 0;
+ A.burst(300, 220, 0.14, 0.14);
+ HUD.say('retrain off the reversal — again', 1200);
+ if (R.misses >= 6) return lose(ctx);
+ }
+ }
+ return true;
+}
+
+function begin(ctx) {
+ R.live = true;
+ R.sinceFlip = 0; R.flipAt = -9;
+ const before = ctx.link.index;
+ ctx.setBaud(Math.max(0, before - 1));
+ ctx.game.flags.retrained = true;
+ A.noCarrier();
+ R.flash = 0.5;
+ R.ans = A.ansam({ gain: 0.11 });
+ HUD.say('the line is going. somebody picked up an extension, or the pair got wet.
' +
+ `you are down to ${ctx.link.label}.`, 5600);
+ setTimeout(() => HUD.say('it is retraining. SPACE on the phase reversal — four hundred and fifty milliseconds,
the same beat as the bong. four clean ones and you get the line back.', 6600), 5800);
+ HUD.hint('SPACEon the reversal');
+ return true;
+}
+
+function win(ctx) {
+ finish(ctx);
+ const spec = ctx.setBaud(Math.min(6, ctx.link.index + 1));
+ A.burst(1200, 2400, 0.3, 0.18);
+ HUD.say(`CONNECT ${spec.rate} — you got it back. it will not always go like that.`, 5200);
+ ctx.phile('retrain');
+ return false;
+}
+
+function lose(ctx) {
+ finish(ctx);
+ A.burst(480, 620, 0.35, 0.16);
+ HUD.say('it settled where it settled. that is what the rest of this looks like now.', 5200);
+ ctx.phile('retrain');
+ return false;
+}
+
+function finish(ctx) {
+ R.done = true; R.live = false; R.armed = false;
+ if (R.ans) { R.ans.stop(); R.ans = null; }
+ ctx.link.glitch = 0;
+ HUD.hint('');
+}
+
+export const isLive = () => R.live;