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;