1/* Terrapin Trail - a comedic Oregon Trail-style RPG following the Grateful Dead
2 on their Spring 1977 tour. Single-file client game; state lives in memory.
3 The Hall of the Fallen persists to the serverless /api/hall backend when one
4 is configured, and falls back to this browser's localStorage otherwise. */
5(function () {
6 "use strict";
7
8 // Whether we're running in a browser. Under Node (the test harness) there is no
9 // DOM, so rendering, animation, and network are skipped -- the pure game logic
10 // still runs, which is what the headless tests exercise.
11 var HAS_DOM = typeof document !== "undefined" && typeof window !== "undefined";
12
13 // ---------- tiny utils ----------
14 // rand is swappable so tests can install a seeded PRNG for deterministic runs.
15 var rand = Math.random;
16 // TT-LUCK: a run started from a shared tour carries a seed, so a friend who
17 // follows a challenge link starts with the same luck (same roster, same first
18 // surprises). Choices that differ make the runs drift apart from there.
19 if (typeof window !== "undefined" && window.__TT_SEED__) {
20 var luckSeed = window.__TT_SEED__ >>> 0;
21 rand = function () {
22 luckSeed = (luckSeed + 0x6D2B79F5) >>> 0;
23 var t = luckSeed;
24 t = Math.imul(t ^ (t >>> 15), t | 1);
25 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27 };
28 }
29 // TT-DNA: the rule set picked in the archive browser (window.__TT_DNA__), or the
30 // game as built. Each value is a multiplier or amount used at one spot below.
31 var DNA = (function () {
32 var d = { code: 0, gas: 1, chaos: 1, wear: 1, hustle: 1, cash: 1, legend: 60, loyal: 1 };
33 var g = (typeof window !== "undefined") && window.__TT_DNA__;
34 if (g) Object.keys(d).forEach(function (k) { if (typeof g[k] === "number" && isFinite(g[k])) d[k] = g[k]; });
35 return d;
36 })();
37 function rnd(a, b) { return a + Math.floor(rand() * (b - a + 1)); }
38 function chance(p) { return rand() < p; }
39 function pick(a) { return a[Math.floor(rand() * a.length)]; }
40 function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
41 function shuffle(a) { return a.slice().sort(function () { return rand() - 0.5; }); }
42 // TT-DNA Taper's code: selling tapes costs Lot Cred when the rule is on.
43 function tapersCode(s, n) {
44 if (!DNA.code) return "";
45 var hit = Math.round(DNA.code * n);
46 s.cred = clamp(s.cred - hit, 0, 100);
47 return " The tapers saw that. -" + hit + " Lot Cred.";
48 }
49 function reduceMotion() { return !!(HAS_DOM && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); }
50 function money(n) { return "$" + n; }
51 function esc(s) {
52 return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
53 }
54
55 // ---------- procedural audio (Web Audio, no asset files) ----------
56 // Fully optional and self-muting. It only does anything in a browser, and only
57 // after the first user gesture unlocks the AudioContext. Under Node (the test
58 // harness) it is inert: every entry point returns immediately, nothing throws.
59 var Sound = (function () {
60 var AC = null, master = null, ready = false, enabled = true, drive = null;
61 var MUTE_KEY = "terrapin_muted";
62 try { if (HAS_DOM && window.localStorage.getItem(MUTE_KEY) === "1") enabled = false; } catch (e) {}
63 function CtxClass() { return HAS_DOM ? (window.AudioContext || window.webkitAudioContext) : null; }
64 function init() {
65 if (AC || !HAS_DOM) return;
66 var C = CtxClass(); if (!C) return;
67 try {
68 AC = new C();
69 master = AC.createGain(); master.gain.value = 0.45; master.connect(AC.destination);
70 ready = true;
71 } catch (e) { AC = null; ready = false; }
72 }
73 function resume() { try { if (AC && AC.state === "suspended") AC.resume(); } catch (e) {} }
74 function live() { return enabled && ready && !!AC; }
75 // one enveloped oscillator note
76 function osc(freq, dur, o) {
77 if (!live()) return;
78 o = o || {};
79 var t0 = AC.currentTime + (o.at || 0);
80 var node = AC.createOscillator(), g = AC.createGain();
81 node.type = o.type || "sine";
82 node.frequency.setValueAtTime(freq, t0);
83 if (o.to) node.frequency.exponentialRampToValueAtTime(Math.max(1, o.to), t0 + dur);
84 var peak = o.gain || 0.18;
85 g.gain.setValueAtTime(0.0001, t0);
86 g.gain.exponentialRampToValueAtTime(peak, t0 + 0.012);
87 g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
88 node.connect(g); g.connect(master);
89 node.start(t0); node.stop(t0 + dur + 0.03);
90 }
91 // a little melody: notes as [freq, dur, startDelay]
92 function seq(notes, o) {
93 if (!live()) return;
94 o = o || {};
95 notes.forEach(function (n) { osc(n[0], n[1], { at: n[2], type: o.type, gain: o.gain, to: n[3] }); });
96 }
97 var A = {
98 tap: function () { osc(680, 0.045, { type: "square", gain: 0.045 }); },
99 buy: function () { seq([[880, 0.05, 0], [1175, 0.07, 0.05]], { type: "square", gain: 0.09 }); },
100 good: function () { seq([[523, 0.1, 0], [659, 0.12, 0.06]], { type: "triangle", gain: 0.13 }); },
101 bad: function () { osc(220, 0.22, { type: "sawtooth", to: 120, gain: 0.12 }); },
102 lock: function (acc, inZone) {
103 if (!inZone) { osc(160, 0.16, { type: "sawtooth", to: 90, gain: 0.12 }); return; }
104 osc(380 + (acc / 100) * 520, 0.13, { type: "sine", gain: 0.16 });
105 },
106 bust: function () { osc(200, 0.35, { type: "sawtooth", to: 70, gain: 0.16 }); },
107 legendary: function () { seq([[523, 0.14, 0], [659, 0.14, 0.1], [784, 0.16, 0.2], [1047, 0.36, 0.32]], { type: "triangle", gain: 0.15 }); },
108 miracle: function () { seq([[784, 0.09, 0], [988, 0.09, 0.06], [1319, 0.2, 0.12]], { type: "sine", gain: 0.12 }); },
109 peak: function () { osc(196, 1.1, { type: "sine", to: 784, gain: 0.14 }); seq([[392, 0.5, 0.15], [587, 0.6, 0.3]], { type: "triangle", gain: 0.1 }); },
110 win: function () { seq([[392, 0.2, 0], [523, 0.2, 0.16], [659, 0.2, 0.32], [784, 0.5, 0.48]], { type: "triangle", gain: 0.16 }); },
111 dosed: function () { osc(330, 1.2, { type: "sawtooth", to: 60, gain: 0.18 }); osc(337, 1.2, { type: "sawtooth", to: 55, gain: 0.13 }); },
112 // subtle rolling engine hum during the driving intermission
113 driveStart: function () {
114 if (!live() || drive) return;
115 try {
116 var o1 = AC.createOscillator(), lp = AC.createBiquadFilter(), g = AC.createGain();
117 o1.type = "sawtooth"; o1.frequency.value = 55;
118 lp.type = "lowpass"; lp.frequency.value = 230;
119 g.gain.value = 0.055;
120 o1.connect(lp); lp.connect(g); g.connect(master); o1.start();
121 drive = { o1: o1, g: g };
122 } catch (e) { drive = null; }
123 },
124 driveStop: function () {
125 if (!drive) return;
126 var d = drive; drive = null;
127 try { d.g.gain.setTargetAtTime(0.0001, AC.currentTime, 0.1); setTimeout(function () { try { d.o1.stop(); } catch (e) {} }, 300); } catch (e) {}
128 },
129 init: init, resume: resume,
130 active: function () { return live(); },
131 isEnabled: function () { return enabled; },
132 toggle: function () {
133 enabled = !enabled;
134 try { if (HAS_DOM) window.localStorage.setItem(MUTE_KEY, enabled ? "0" : "1"); } catch (e) {}
135 if (!enabled) A.driveStop();
136 return enabled;
137 }
138 };
139 return A;
140 })();
141
142 // ---------- ASCII art ----------
143 var LOGO =
144"████████╗███████╗██████╗ ██████╗ █████╗ ██████╗ ██╗███╗ ██╗\n" +
145"╚══██╔══╝██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔══██╗██║████╗ ██║\n" +
146" ██║ █████╗ ██████╔╝██████╔╝███████║██████╔╝██║██╔██╗ ██║\n" +
147" ██║ ██╔══╝ ██╔══██╗██╔══██╗██╔══██║██╔═══╝ ██║██║╚██╗██║\n" +
148" ██║ ███████╗██║ ██║██║ ██║██║ ██║██║ ██║██║ ╚████║\n" +
149" ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝\n" +
150"████████╗██████╗ █████╗ ██╗██╗ \n" +
151"╚══██╔══╝██╔══██╗██╔══██╗██║██║ \n" +
152" ██║ ██████╔╝███████║██║██║ \n" +
153" ██║ ██╔══██╗██╔══██║██║██║ \n" +
154" ██║ ██║ ██║██║ ██║██║███████╗\n" +
155" ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝";
156
157 var GAMEOVER_BEARS = String.raw` ()() ()() ()()
158 (@ . @) (x . x) (@ . @)
159\( )/ <( )> \( )/
160 / \ / \ / \ `;
161
162 var SMALL_VAN = String.raw` ______
163 _/TERRPN\_
164 ( o o )>
165 "-(O)---(O)"`;
166
167 // Roadside signs the van rolls past. Rebuilt at random every leg so the loading
168 // scene is never the same twice (no more lonely Phish billboard).
169 var ROAD_SIGNS = [
170 "GAS - FOOD - MIRACLES", "GAS - GRASS - or CASH", "LAST BURRITOS: 2 MI",
171 "MYSTERY SPOT ->", "WELCOME TO OHIO (again)", "STEAKHOUSE NEXT EXIT",
172 "WORLD'S LARGEST BALL OF TWINE", "TOLL AHEAD: EXACT CHANGE ONLY",
173 "HONK FOR JERRY", "SCENIC OVERLOOK 1 MI", "FIREWORKS - PECANS - SALVATION",
174 "BAIT - TACKLE - BOOTLEG TAPES", "DIESEL - COFFEE - REGRET",
175 "NEXT SERVICES: 47 MI", "DEER XING (they're on tour too)",
176 "SEE THE 2-HEADED CALF", "NUCLEAR PLANT TOURS ->", "REST STOP: ONE WORKING FAUCET",
177 "PRAY FOR US ALL", "DRUM CIRCLE, NEXT LEFT", "CAUTION: SLOW HEADS MERGING"
178 ];
179 // scrolling roadside scenery (each line padded then doubled for a seamless loop)
180 function buildScenery() {
181 function pad(s, n) { while (s.length < n) s += " "; return s.slice(0, n); }
182 var L = 96;
183 var s = shuffle(ROAD_SIGNS);
184 var seg = [
185 " .-. \\ | / .--. .-. ",
186 String.raw` /\/\ /\ /\/\/\ /\ /\ /\/\ /\/\/\ /\ `,
187 " ^ [ " + s[0] + " ] ^ " + s[1] + " ^ ^ ",
188 " (oo) o/`|` ^ " + s[2] + " ^ " + s[3] + " (oo) "
189 ];
190 return seg.map(function (l) { return pad(l, L) + pad(l, L); }).join("\n");
191 }
192 var SCENERY = buildScenery(); // default/fallback; each leg builds a fresh one
193
194 var ROAD = (function () { var s = ""; for (var i = 0; i < 60; i++) s += "_ . "; return s; })();
195
196 var DRIVE_CAPTIONS = [
197 "{doses} is navigating by vibes alone.",
198 "{randy} is asleep across the amps again.",
199 "{deb} knows a shortcut. This is a lie.",
200 "The 8-track is stuck on 'Terrapin Station.' Nobody minds.",
201 "You pass a sign: MYSTERY SPOT, 4 MI. You do not stop. You want to.",
202 "Somebody in the back is rolling something. A tape, probably.",
203 "The bus finds exactly 55 mph and refuses, spiritually, to exceed it.",
204 "A trooper passes. Everyone becomes a statue. He waves.",
205 "Three hitchhikers, one thumb between them. You wave back.",
206 "Debate erupts over the best '72 'Dark Star.' It lasts four states.",
207 "The heater only works if you believe in it. You believe.",
208 "A cop car merges. {randy} hides. {randy} was never in danger.",
209 "Toll booth. Everyone pretends to look for change until {deb} finds a five.",
210 "The van smells like patchouli, propane, and destiny.",
211 "A billboard for a steakhouse. The whole van goes quiet and sad.",
212 "Somebody starts 'He's Gone' a cappella. It is not, technically, in tune.",
213 "Rest stop. Twelve people, one working faucet, infinite patience.",
214 "The map is upside down. {deb} swears this is correct.",
215 "Someone's foot is out the window. It has been for ninety miles.",
216 "A '76 that broke down waves as you pass. You'll see them at the show.",
217 "{doses} explains time zones. They are wrong, beautifully, at length.",
218 "You count nineteen other vans in an hour. It's a migration.",
219 "The dashboard Jesus and the dashboard skeleton have reached an understanding.",
220 "{randy} is convinced the moon is following the van. Nobody corrects them.",
221 "Somebody finally admits they've never actually heard the studio albums.",
222 "A hand-lettered sign in the next van's window reads: HONK FOR JERRY.",
223 "You honk for Jerry.",
224 "The gas gauge and the odometer are having a private disagreement.",
225 "Cornfields. Then cornfields. Then, for variety, some soybeans.",
226 "A cop pulls alongside, nods at the tie-dye, and drives on. Close one.",
227 "Everyone smells the venue before they see it. Popcorn and possibility.",
228 "The van hits a pothole and plays a chord nobody could name.",
229 "{deb} is teaching everyone a card game with rules that change when they're losing.",
230 "You pass the exact spot where, last tour, everything went sideways. You wave.",
231 "{randy} has named the van. The van's name is 'The Other One.'",
232 "A thunderhead the size of Ohio sits on the horizon, minding its business.",
233 "The 8-track eats a tape. A small, solemn funeral is held at 70 mph.",
234 "Two lanes, one dream, half a tank.",
235 "The line at the on-ramp is all the same bumper stickers as yours.",
236 "Somebody starts a wave from van to van across three lanes of traffic.",
237 "You realize you have no idea what day it is, and it doesn't matter at all."
238 ];
239
240 // ---------- data ----------
241 var ROLES = [
242 { id: "trust", name: "The Trust-Fund Kid",
243 blurb: "Daddy's Amex and a brand-new van. Rolls in loaded, but the lot can smell it.",
244 note: "big Cash, minty Van -- low Lot Cred",
245 stats: { cash: 340, gas: 8, grub: 14, van: 95, vibes: 70, tapes: 2, blanks: 8, cred: 15 },
246 taper: 0, doseImmune: false },
247 { id: "taper", name: "The Veteran Taper",
248 blurb: "Nakamichi deck, mic stand, a binder of masters. Knows every soundman by name.",
249 note: "best Gear + Tapes, knows the scene -- a bad back",
250 stats: { cash: 170, gas: 7, grub: 11, van: 78, vibes: 66, tapes: 6, blanks: 14, cred: 55 },
251 taper: 25, doseImmune: false },
252 { id: "burnout", name: "The Burnout",
253 blurb: "No money, no plan, no problem. Cannot be dosed -- already there. Everybody's buddy.",
254 note: "max Lot Cred, immune to bad trips -- flat broke",
255 stats: { cash: 45, gas: 5, grub: 8, van: 62, vibes: 82, tapes: 3, blanks: 5, cred: 90 },
256 taper: 0, doseImmune: true },
257 { id: "grandma", name: "The Deadicated Grandma",
258 blurb: "On tour since Pigpen. Knows every roadie, every backroad, and at least one bail bondsman. Packs actual snacks.",
259 note: "big Lot Cred, a pantry full of Grub -- a creaky old Van",
260 stats: { cash: 120, gas: 7, grub: 30, van: 55, vibes: 76, tapes: 4, blanks: 10, cred: 82 },
261 taper: 10, doseImmune: false },
262 { id: "roadie", name: "The Off-Duty Roadie",
263 blurb: "You humped gear for a support act until they folded in Ohio. You know knots, amps, and exactly which security guards are cool.",
264 note: "great with the Van, solid Gear -- middling everything else",
265 stats: { cash: 130, gas: 8, grub: 14, van: 90, vibes: 64, tapes: 3, blanks: 9, cred: 60 },
266 taper: 12, doseImmune: false },
267 { id: "professor", name: "The Spun Professor",
268 blurb: "Tenured, on sabbatical, \"researching American folk ritual.\" Grant money in one pocket, a field notebook in the other. Cannot stop explaining.",
269 note: "loaded with grant Cash -- the lot finds you exhausting",
270 stats: { cash: 380, gas: 9, grub: 16, van: 90, vibes: 58, tapes: 1, blanks: 6, cred: 8 },
271 taper: 6, doseImmune: false },
272 { id: "runaway", name: "The Runaway",
273 blurb: "Seventeen, a backpack, and a note left on the kitchen table. This tour is the first thing that has ever felt like home.",
274 note: "sky-high Vibes and open-hearted -- flat broke, green as spring",
275 stats: { cash: 22, gas: 4, grub: 7, van: 58, vibes: 92, tapes: 0, blanks: 4, cred: 45 },
276 taper: 0, doseImmune: false }
277 ];
278
279 // A tiny ASCII "profile icon" per role for the picker card -- three lines, same
280 // plain-parens-and-symbols terminal style as the rest of the game's ASCII art.
281 var ROLE_ART = {
282 trust: " $$$\n[o o]\n \\_/",
283 taper: " ))((\n(o.o)\n |||",
284 burnout: " ~~~\n(x.x)\n \\_/",
285 grandma: " (())\n(^.^)\n/||\\",
286 roadie: " [=]\n(o-o)\n /_\\",
287 professor: ",--.\n(o_o)~\n/|\\",
288 runaway: " ***\n(*o*)\n /|\\"
289 };
290
291 // The playable tours. Each is a real Grateful Dead run from that year, curated
292 // into a drivable route: every stop is an actual show (venue/city/date), and the
293 // `ymd` is its Internet Archive date, so the live "Tonight's Tapes" sidebar pulls
294 // that night's real recordings. `miles` is the driving distance from the previous
295 // stop (first is 0); legs are kept under the gas cap so any tour is finishable.
296 // `legendary` = a storied show (bonus payoff + a legendary tape); `finale` = the
297 // last night. Add a year by appending a TOURS entry -- everything else generalizes.
298 var TOURS = [
299 { year: 1970, label: "1970: The Fillmore Years",
300 blurb: "Workingman's Dead, American Beauty, and long nights at the Capitol Theatre and the Fillmores. Short hops, tight rooms.",
301 stops: [
302 { venue: "Capitol Theater", city: "Port Chester, NY", date: "Mar 21", ymd: "1970-03-21", miles: 0, legendary: true },
303 { venue: "Worcester Polytechnic Inst.", city: "Worcester, MA", date: "May 9", ymd: "1970-05-09", miles: 145 },
304 { venue: "Fillmore East", city: "New York, NY", date: "Jul 11", ymd: "1970-07-11", miles: 175 },
305 { venue: "Gym, S.U.N.Y.", city: "Stony Brook, NY", date: "Oct 31", ymd: "1970-10-31", miles: 60 },
306 { venue: "46th St. Rock Palace", city: "Brooklyn, NY", date: "Nov 11", ymd: "1970-11-11", miles: 55, finale: true }
307 ] },
308 { year: 1971, label: "1971: Skull & Roses",
309 blurb: "The self-titled live album is being cut on this very run. Pigpen's out front and the whole country's the route.",
310 stops: [
311 { venue: "Capitol Theatre", city: "Port Chester, NY", date: "Feb 19", ymd: "1971-02-19", miles: 0 },
312 { venue: "Boston Music Hall", city: "Boston, MA", date: "Apr 8", ymd: "1971-04-08", miles: 215 },
313 { venue: "Allegheny College", city: "Meadville, PA", date: "Apr 15", ymd: "1971-04-15", miles: 570 },
314 { venue: "Fillmore East", city: "New York, NY", date: "Apr 29", ymd: "1971-04-29", miles: 420, legendary: true },
315 { venue: "Auditorium Theatre", city: "Chicago, IL", date: "Oct 22", ymd: "1971-10-22", miles: 790 },
316 { venue: "Hill Auditorium", city: "Ann Arbor, MI", date: "Dec 15", ymd: "1971-12-15", miles: 245, finale: true }
317 ] },
318 { year: 1972, label: "1972: Europe '72",
319 blurb: "Across the water in a caravan of rented gear and questionable food. The tapes from this run become scripture.",
320 stops: [
321 { venue: "Wembley Empire Pool", city: "London, England", date: "Apr 8", ymd: "1972-04-08", miles: 0 },
322 { venue: "Rheinhalle", city: "Duesseldorf, Germany", date: "Apr 24", ymd: "1972-04-24", miles: 320 },
323 { venue: "Concertgebouw", city: "Amsterdam, Netherlands", date: "May 10", ymd: "1972-05-10", miles: 140 },
324 { venue: "Strand Lyceum", city: "London, England", date: "May 24", ymd: "1972-05-24", miles: 220, legendary: true, finale: true }
325 ] },
326 { year: 1973, label: "1973: Wake of the Flood",
327 blurb: "Bigger halls, a new record, and the 600,000-strong Summer Jam at Watkins Glen. The road stretches out.",
328 stops: [
329 { venue: "International Amphitheatre", city: "Chicago, IL", date: "Feb 19", ymd: "1973-02-19", miles: 0 },
330 { venue: "Pershing Municipal Aud.", city: "Lincoln, NE", date: "Feb 26", ymd: "1973-02-26", miles: 530 },
331 { venue: "Baltimore Civic Center", city: "Baltimore, MD", date: "Mar 26", ymd: "1973-03-26", miles: 1080 },
332 { venue: "Boston Garden", city: "Boston, MA", date: "Apr 2", ymd: "1973-04-02", miles: 400 },
333 { venue: "RFK Stadium", city: "Washington, DC", date: "Jun 9", ymd: "1973-06-09", miles: 440 },
334 { venue: "Grand Prix Racecourse", city: "Watkins Glen, NY", date: "Jul 27", ymd: "1973-07-27", miles: 300, legendary: true },
335 { venue: "The Spectrum", city: "Philadelphia, PA", date: "Sep 21", ymd: "1973-09-21", miles: 250 },
336 { venue: "Kiel Auditorium", city: "St. Louis, MO", date: "Oct 29", ymd: "1973-10-29", miles: 900, finale: true }
337 ] },
338 { year: 1974, label: "1974: The Wall of Sound",
339 blurb: "The biggest PA ever built rolls city to city on its own semi fleet. Then the band walks off for a year.",
340 stops: [
341 { venue: "Winterland Arena", city: "San Francisco, CA", date: "Feb 22", ymd: "1974-02-22", miles: 0 },
342 { venue: "Adams Field House", city: "Missoula, MT", date: "May 14", ymd: "1974-05-14", miles: 1050 },
343 { venue: "Edmundson Pavilion", city: "Seattle, WA", date: "May 21", ymd: "1974-05-21", miles: 480 },
344 { venue: "Hollywood Bowl", city: "Los Angeles, CA", date: "Jul 21", ymd: "1974-07-21", miles: 1150 },
345 { venue: "Winterland Arena", city: "San Francisco, CA", date: "Oct 19", ymd: "1974-10-19", miles: 380, legendary: true, finale: true }
346 ] },
347 { year: 1975, label: "1975: The Hiatus",
348 blurb: "The bus barely leaves the Bay this year -- a handful of one-offs and benefits. Blues for Allah is cooking at home.",
349 stops: [
350 { venue: "Kezar Stadium", city: "San Francisco, CA", date: "Mar 23", ymd: "1975-03-23", miles: 0 },
351 { venue: "Winterland Arena", city: "San Francisco, CA", date: "Jun 17", ymd: "1975-06-17", miles: 5 },
352 { venue: "Great American Music Hall", city: "San Francisco, CA", date: "Aug 13", ymd: "1975-08-13", miles: 3, legendary: true },
353 { venue: "Lindley Meadows, GG Park", city: "San Francisco, CA", date: "Sep 28", ymd: "1975-09-28", miles: 4, finale: true }
354 ] },
355 { year: 1976, label: "1976: The Comeback",
356 blurb: "Off the hiatus and into the theaters -- smaller rooms, a slower groove, and the drummers back to two.",
357 stops: [
358 { venue: "Boston Music Hall", city: "Boston, MA", date: "Jun 10", ymd: "1976-06-10", miles: 0 },
359 { venue: "Beacon Theatre", city: "New York, NY", date: "Jun 14", ymd: "1976-06-14", miles: 215 },
360 { venue: "Capitol Theater", city: "Passaic, NJ", date: "Jun 18", ymd: "1976-06-18", miles: 25 },
361 { venue: "The Tower Theatre", city: "Upper Darby, PA", date: "Jun 22", ymd: "1976-06-22", miles: 90, legendary: true },
362 { venue: "Auditorium Theatre", city: "Chicago, IL", date: "Jun 29", ymd: "1976-06-29", miles: 760 },
363 { venue: "Capital Centre", city: "Landover, MD", date: "Sep 25", ymd: "1976-09-25", miles: 700 },
364 { venue: "Cobo Arena", city: "Detroit, MI", date: "Oct 3", ymd: "1976-10-03", miles: 520, finale: true }
365 ] },
366 { year: 1977, label: "1977: The Spring Tour",
367 blurb: "The hot streak. Ten nights from the Palladium to legendary Barton Hall to the Hartford finale -- the tape-trader's holy year.",
368 stops: [
369 { venue: "The Palladium", city: "New York, NY", date: "May 4", ymd: "1977-05-04", miles: 0 },
370 { venue: "Veterans Mem. Coliseum", city: "New Haven, CT", date: "May 5", ymd: "1977-05-05", miles: 75 },
371 { venue: "Boston Garden", city: "Boston, MA", date: "May 7", ymd: "1977-05-07", miles: 135 },
372 { venue: "Barton Hall, Cornell", city: "Ithaca, NY", date: "May 8", ymd: "1977-05-08", miles: 330, legendary: true },
373 { venue: "War Memorial Aud.", city: "Buffalo, NY", date: "May 9", ymd: "1977-05-09", miles: 150 },
374 { venue: "St. Paul Civic Center", city: "St. Paul, MN", date: "May 11", ymd: "1977-05-11", miles: 900 },
375 { venue: "Auditorium Theatre", city: "Chicago, IL", date: "May 13", ymd: "1977-05-13", miles: 410 },
376 { venue: "Fox Theatre", city: "Atlanta, GA", date: "May 19", ymd: "1977-05-19", miles: 720 },
377 { venue: "Sportatorium", city: "Pembroke Pines, FL", date: "May 22", ymd: "1977-05-22", miles: 660 },
378 { venue: "Hartford Civic Center", city: "Hartford, CT", date: "May 28", ymd: "1977-05-28", miles: 1180, finale: true }
379 ] },
380 { year: 1978, label: "1978: Shakedown Street",
381 blurb: "Disco-tinged and road-hardened -- Florida to the Northeast, with the Pyramids waiting at the far end of the year.",
382 stops: [
383 { venue: "The Sportatorium", city: "Pembroke Pines, FL", date: "Apr 7", ymd: "1978-04-07", miles: 0 },
384 { venue: "Cameron Indoor Stadium", city: "Durham, NC", date: "Apr 12", ymd: "1978-04-12", miles: 650, legendary: true },
385 { venue: "Civic Arena", city: "Pittsburgh, PA", date: "Apr 18", ymd: "1978-04-18", miles: 420 },
386 { venue: "Horton Field House", city: "Normal, IL", date: "Apr 24", ymd: "1978-04-24", miles: 470 },
387 { venue: "Onondaga County War Mem.", city: "Syracuse, NY", date: "May 9", ymd: "1978-05-09", miles: 900 },
388 { venue: "Providence Civic Center", city: "Providence, RI", date: "May 14", ymd: "1978-05-14", miles: 300, finale: true }
389 ] },
390 { year: 1979, label: "1979: Brent Steps In",
391 blurb: "New keyboard player, new decade coming. Big arenas east of the Mississippi and a Halloween blowout at Nassau.",
392 stops: [
393 { venue: "Madison Square Garden", city: "New York, NY", date: "Jan 8", ymd: "1979-01-08", miles: 0 },
394 { venue: "The Spectrum", city: "Philadelphia, PA", date: "Jan 12", ymd: "1979-01-12", miles: 95 },
395 { venue: "Veterans Mem. Coliseum", city: "New Haven, CT", date: "Jan 17", ymd: "1979-01-17", miles: 130 },
396 { venue: "Masonic Temple", city: "Detroit, MI", date: "Jan 21", ymd: "1979-01-21", miles: 630 },
397 { venue: "Hampton Coliseum", city: "Hampton, VA", date: "May 4", ymd: "1979-05-04", miles: 700 },
398 { venue: "Rec Hall, Penn State", city: "State College, PA", date: "May 8", ymd: "1979-05-08", miles: 300 },
399 { venue: "Alumni Stadium, U. Mass", city: "Amherst, MA", date: "May 12", ymd: "1979-05-12", miles: 350 },
400 { venue: "Nassau Coliseum", city: "Uniondale, NY", date: "Oct 31", ymd: "1979-10-31", miles: 200, legendary: true, finale: true }
401 ] }
402 ];
403 var DEFAULT_TOUR_YEAR = 1977;
404 var TOUR_COLLECTION = "GratefulDead"; // TT-INFINITY: band collection for the music panel
405
406 // TT-SETLIST: the songs really played each night, from that night's recording on the
407 // Live Music Archive (fetched when the van reaches the stop; /api/setlist keeps them).
408 // They only add lines to the concert text and never touch the run's luck, so two
409 // players on the same luck still get the same game whether or not a setlist loaded.
410 var SETLISTS = {};
411 function setlistKey(stop) { return TOUR_COLLECTION + ":" + stop.ymd; }
412 function prefetchSetlist(stop) {
413 if (!HAS_DOM || !stop || !stop.ymd || typeof fetch !== "function") return;
414 var k = setlistKey(stop);
415 if (k in SETLISTS) return;
416 SETLISTS[k] = null;
417 fetch("/api/setlist/" + encodeURIComponent(TOUR_COLLECTION) + "/" + stop.ymd + (stop.identifier ? "?id=" + encodeURIComponent(stop.identifier) : ""))
418 .then(function (r) { return r.ok ? r.json() : null; })
419 .then(function (d) { SETLISTS[k] = (d && d.songs) || []; })
420 .catch(function () { SETLISTS[k] = []; });
421 }
422 function setlistLines(stop) {
423 var songs = SETLISTS[setlistKey(stop)];
424 if (!songs || songs.length < 3) return [];
425 var longest = songs.reduce(function (a, b) { return b.secs > a.secs ? b : a; });
426 var lines = ["They open with " + songs[0].title + "."];
427 if (longest.secs >= 600) lines.push(longest.title + " stretches past " + Math.floor(longest.secs / 60) + " minutes. Nobody on the floor wants it to end.");
428 else if (longest !== songs[0]) lines.push(longest.title + " is the one everybody talks about in the parking lot after.");
429 lines.push("They close with " + songs[songs.length - 1].title + ".");
430 return lines;
431 }
432 var TOUR_BAND = null; // TT-INFINITY: loaded band display name (null = built-in GD)
433
434 // The active tour. STOPS and TOUR_YEAR are read all over the game at call time, so
435 // swapping the active tour is just re-pointing them (see setTour). Every finished
436 // run is stamped with TOUR_YEAR, so the Hall keeps a separate high-score list per
437 // year (HALL_SEASONS maps each year to its label).
438 var STOPS = null, TOUR_YEAR = null, TOUR_LABEL = null;
439 var HALL_SEASONS = {};
440 TOURS.forEach(function (t) { HALL_SEASONS[t.year] = t.label; });
441 function tourByYear(y) { return TOURS.filter(function (t) { return t.year === y; })[0]; }
442 function setTour(y) {
443 var t = tourByYear(y) || tourByYear(DEFAULT_TOUR_YEAR);
444 STOPS = t.stops; TOUR_YEAR = t.year; TOUR_LABEL = t.label;
445 }
446 setTour(DEFAULT_TOUR_YEAR);
447 function seasonLabel(y) { return HALL_SEASONS[y] || ("The " + y + " Tour"); }
448 function legendaryStopOf(t) { var s = t.stops.filter(function (x) { return x.legendary; })[0]; return s || t.stops[t.stops.length - 1]; }
449
450 // TT-INFINITY: replace the built-in tours with a tour generated from a pasted
451 // Internet Archive link (set on window.__TT_GENERATED__ before this script runs).
452 function applyGeneratedTour() {
453 var gen = (typeof window !== "undefined") && window.__TT_GENERATED__;
454 if (!gen || !gen.tours || !gen.tours.length) return;
455 TOURS.length = 0;
456 gen.tours.forEach(function (t) { TOURS.push(t); });
457 HALL_SEASONS = {};
458 TOURS.forEach(function (t) { HALL_SEASONS[t.year] = t.label; });
459 DEFAULT_TOUR_YEAR = TOURS[0].year;
460 if (gen.tours[0].collection) TOUR_COLLECTION = gen.tours[0].collection;
461 TOUR_BAND = gen.band || (gen.tours[0] && gen.tours[0].band) || null;
462 setTour(DEFAULT_TOUR_YEAR);
463 }
464
465 // TT-BAND: the game's jokes are written for the Grateful Dead. For a tour by any
466 // band outside the Dead family, the handful of lines that name the Dead (Jerry,
467 // Pigpen, stealies, 'Dark Star') are swapped for versions naming the band, as
468 // each screen is drawn. The general parking-lot humor fits any touring band and
469 // stays as written.
470 var DEAD_FAMILY = /grateful dead|dead (&|and) company|garcia|phil lesh|ratdog|furthur|bob weir|the other ones|dark star orchestra|almost dead|^the dead$/i;
471 var bandSwaps = null;
472 function bandify(html) {
473 if (!TOUR_BAND || DEAD_FAMILY.test(TOUR_BAND)) return html;
474 if (!bandSwaps) {
475 var b = esc(TOUR_BAND), B = b.toUpperCase(), q = "(?:'|'|')";
476 bandSwaps = [
477 [/HONK FOR JERRY/g, "HONK FOR " + B],
478 [/honk for Jerry/g, "honk for " + b],
479 [new RegExp("the best " + q + "72 " + q + "Dark Star\\." + q, "g"), "the best version of " + b + "'s longest jam."],
480 [/On tour since Pigpen\./g, "On tour since the band's first gig."],
481 [/Jerry would not approve\./g, b + " would not approve."],
482 [/unlicensed stealie shirts/g, "unlicensed " + b + " shirts"],
483 [/not your stealie monkeys/g, "not your monkeys"],
484 [/Steal Your Face on the tailgate/g, b + " stickers on the tailgate"],
485 [/Very un-Dead of you\./g, "Very off-brand for a " + b + " fan."],
486 [/The Deadicated Grandma/g, "The Dedicated Grandma"],
487 [/following the Grateful Dead(\\?'|')s real tours across the decade[^.]*\./g, "following " + b + " on real tours built from the Live Music Archive."],
488 [/Every year is a real Grateful Dead run[^<]*/g, "Every tour here is a real " + b + " run, built from the Live Music Archive."]
489 ];
490 if (/^phish$/i.test(TOUR_BAND)) bandSwaps.push([/What are you, a Phish fan\?/g, "What are you, a Deadhead?"]);
491 }
492 for (var i = 0; i < bandSwaps.length; i++) html = html.replace(bandSwaps[i][0], bandSwaps[i][1]);
493 return html;
494 }
495
496 // ---------- multi-year campaign ----------
497 // A campaign is N consecutive tour years played back to back with the same
498 // character, crew, and van -- picked once on the "campaign length" screen
499 // between choosing a tour and choosing a role. MAX_CAMPAIGN caps how many
500 // years can be chained (there just aren't more than a few consecutive years
501 // left once you're picking from the back of the decade).
502 var MAX_CAMPAIGN = 4;
503 function campaignYearsFrom(startYear, n) {
504 var years = TOURS.map(function (t) { return t.year; });
505 var idx = years.indexOf(startYear);
506 if (idx === -1) idx = 0;
507 var out = [];
508 for (var i = 0; i < n && idx + i < years.length; i++) out.push(years[idx + i]);
509 return out.length ? out : [startYear];
510 }
511 var PENDING_CAMPAIGN_LEN = 1;
512
513 // The van-mate roster. Each has a stable archetype `id` used to wire the
514 // character into bespoke events (and to resolve their name in flavor text via
515 // {id} tokens -- see resolveCrew). `signature` ones anchor their own storylines;
516 // the rest are good company who ride along in the shared events. `name` is the
517 // default -- the player can rename any of them at the start of a trip.
518 // Each archetype ships with two named variants (a female and a male take on the
519 // same character); the game rolls one per trip (see S.roster), so a run might field
520 // Cosmic Deb or Cosmic Dean, Wharf Rat Rae or Randy, and so on. Perks, traits, and
521 // blurbs are archetype-level and gender-neutral, so either name reads right. `name`
522 // is just the first variant, used as a fallback before a roster is rolled.
523 var VANMATES = [
524 { id: "deb", names: ["Cosmic Deb", "Cosmic Dean"], trait: "knows a guy", signature: true,
525 perk: "Their connections cut $1 off every Shakedown price.",
526 blurb: "Has a cousin, a connection, or a shortcut for every situation. Half of them are even real." },
527 { id: "randy", names: ["Wharf Rat Rae", "Wharf Rat Randy"], trait: "always wanders off", signature: true,
528 perk: "Wanders back from rest stops with free tapes or snacks.",
529 blurb: "Sweet, unsinkable, and gone the second you look away. Always comes back with a story." },
530 { id: "doses", names: ["Doses Maggie", "Doses McGee"], trait: "do not take snacks from this one", signature: true,
531 perk: "The pot's always on -- the crew eats 1 less Grub per leg.",
532 blurb: "Generous to a fault with a cooler full of unlabeled provisions. Cooks, too. Bravely." },
533 { id: "rita", names: ["Reel-to-Reel Rita", "Reel-to-Reel Reggie"], trait: "hears the whole mix", signature: false,
534 perk: "Golden ears clean up every recording (+tape quality).",
535 blurb: "A taper's taper. Can name the venue from three seconds of room tone. Keeps morale steady." },
536 { id: "hitch", names: ["Interstate Hazel", "Interstate Hank"], trait: "thumbs rides, tells tales", signature: false,
537 perk: "Knows the backroads -- smoother, safer legs (less wear, fewer surprises).",
538 blurb: "You picked them up outside Toledo two tours ago and they just... stayed. Knows every backroad." },
539 { id: "bird", names: ["Little Wren", "Little Finch"], trait: "first tour, all heart", signature: false,
540 perk: "Pure, contagious wonder -- +2 Vibes at every show.",
541 blurb: "Seventeen and luminous with it. Everything is the best thing that has ever happened." }
542 ];
543 VANMATES.forEach(function (v) { v.name = v.names[0]; }); // default-name fallback
544 var CREW_MIN = 1, CREW_MAX = 4;
545 function vanmateById(id) { return VANMATES.filter(function (v) { return v.id === id; })[0]; }
546 // Roll a fresh set of variant names for a trip. Stored on S.roster so the crew
547 // screen, flavor text, and saves all agree on who's riding.
548 function rollRoster() { var r = {}; VANMATES.forEach(function (v) { r[v.id] = pick(v.names); }); return r; }
549 function rosterName(s, id) {
550 if (s && s.roster && s.roster[id]) return s.roster[id];
551 var v = vanmateById(id); return v ? v.name : id;
552 }
553 // The three signature van-mates ride along by default, preserving the classic trio.
554 var DEFAULT_CREW = ["deb", "randy", "doses"];
555
556 var LOT_ITEMS = [
557 { id: "cheese", who: "Miracle Mike", name: "Grilled Cheese", desc: "American cheese, American dream.", price: 2,
558 apply: function (s) { s.grub = clamp(s.grub + 6, 0, CAP.grub); return "+6 Grub. Mike nods sagely."; } },
559 { id: "burrito", who: "Sunflower", name: "Veggie Burrito of Uncertain Origin", desc: "Filling. Mostly.", price: 3,
560 apply: function (s) {
561 if (chance(0.2)) { s.vibes = clamp(s.vibes - 12, 0, CAP.vibes); return "The beans had OTHER ideas. -12 Vibes."; }
562 s.grub = clamp(s.grub + 10, 0, CAP.grub); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes);
563 return "+10 Grub, +4 Vibes. Delicious, actually.";
564 } },
565 { id: "blanks", who: "Wharf Rat Randy", name: "Blank Maxell XLII (3-pack)", desc: "The taper's true currency.", price: 4,
566 apply: function (s) { s.blanks += 3; return "+3 blank tapes."; } },
567 { id: "gas", who: "The Gas Guy", name: "Gas (5 units)", desc: "Barely street legal. 65 cents a gallon.", price: 4,
568 apply: function (s) { s.gas = clamp(s.gas + 5, 0, CAP.gas); return "+5 Gas. The van drinks."; } },
569 { id: "tea", who: "Grandma Sunshine", name: "A cup of the Kind Tea", desc: "Herbal. Restorative. Legal-ish.", price: 3,
570 apply: function (s) { s.vibes = clamp(s.vibes + 8, 0, CAP.vibes); return "+8 Vibes. Everything is soft now."; } },
571 { id: "repair", who: "Cosmic Charlie's Garage", name: "Patch up the van (+22 Van)", desc: "Duct tape, zip ties, and a prayer to Bill.", price: 14,
572 apply: function (s) { s.van = clamp(s.van + 22, 0, 100); return "+22 Van. She'll run. Probably."; } },
573 { id: "ticket", who: "\"Legit\" Larry", name: "A \"Miracle\" Ticket", desc: "Section NaN, Row void. He seems trustworthy.", price: 40,
574 apply: function (s) {
575 if (s.hasTicket) return "You already have a way in. Larry shrugs.";
576 if (chance(0.6)) { s.hasTicket = true; return "It's REAL. You're in tonight, guaranteed."; }
577 return "Larry has vanished. The ticket says 'ADMIT NaN'. Burned.";
578 } },
579 { id: "nitrous", who: "The Nitrous Guy", name: "A Balloon", desc: "Everyone is begging you not to.", price: 5,
580 apply: function (s) { s.vibes = clamp(s.vibes - 10, 0, CAP.vibes); s.cred = clamp(s.cred - 6, 0, 100);
581 return "-10 Vibes, -6 Lot Cred. The whole lot saw you do that."; } },
582 { id: "tiedye", who: "The Tie-Dye Lady", name: "A Fresh Tie-Dye", desc: "Spirals that violate several laws of optics.", price: 6,
583 apply: function (s) { s.cred = clamp(s.cred + 8, 0, 100); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return "+8 Lot Cred, +3 Vibes. You look the part now."; } },
584 { id: "oranges", who: "The Orange Guy", name: "A Bag of Oranges", desc: "Vitamin C for the road-weary.", price: 3,
585 apply: function (s) { s.grub = clamp(s.grub + 5, 0, CAP.grub); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return "+5 Grub, +3 Vibes. The crew perks up."; } },
586 { id: "tamales", who: "Tamale Estrella", name: "Two Tamales, Still Warm", desc: "Wrapped in a corn husk and a little bit of love.", price: 4,
587 apply: function (s) { s.grub = clamp(s.grub + 9, 0, CAP.grub); return "+9 Grub. Best deal on Shakedown Street."; } },
588 { id: "veggiedogs", who: "Tofu Tony", name: "Two Tofu Dogs", desc: "Technically food. Spiritually food.", price: 3,
589 apply: function (s) { s.grub = clamp(s.grub + 7, 0, CAP.grub); return "+7 Grub. Tony winks. You wish he hadn't."; } },
590 { id: "coffee", who: "Sister Midnight", name: "Cowboy Coffee", desc: "Strong enough to wake the dead. Sorry.", price: 2,
591 apply: function (s) { s.vibes = clamp(s.vibes + 7, 0, CAP.vibes); return "+7 Vibes. Your heart is a bass drum now."; } },
592 { id: "crystal", who: "Moonbeam", name: "A \"Charged\" Quartz", desc: "It's for protection. From what, she won't say.", price: 5,
593 apply: function (s) { s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); s.cred = clamp(s.cred + 3, 0, 100); return "+4 Vibes, +3 Lot Cred. You feel vaguely shielded."; } },
594 { id: "cookies", who: "Somebody's Actual Grandma", name: "A Bag of Cookies", desc: "These ones are JUST cookies. She promises.", price: 3,
595 apply: function (s) { s.grub = clamp(s.grub + 6, 0, CAP.grub); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return "+6 Grub, +3 Vibes. Just cookies. What a concept."; } },
596 { id: "pin", who: "The Pin Guy", name: "An Enamel Bolt Pin", desc: "Instant flair for your jacket.", price: 2,
597 apply: function (s) { s.cred = clamp(s.cred + 4, 0, 100); return "+4 Lot Cred. You are now 4% cooler."; } },
598 { id: "lemonade", who: "Two Kids and a Card Table", name: "Fresh Lemonade", desc: "Wildly overpriced. You pay anyway.", price: 2,
599 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); s.cred = clamp(s.cred + 2, 0, 100); return "+3 Vibes, +2 Lot Cred. Supporting small business."; } },
600 { id: "djembe", who: "Djembe Dan", name: "A Second-Hand Djembe", desc: "Your ticket into any drum circle.", price: 9,
601 apply: function (s) { s.cred = clamp(s.cred + 6, 0, 100); s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return "+6 Lot Cred, +2 Vibes. Do NOT bring it in the van at night."; } },
602 { id: "kombucha", who: "Fermenta", name: "Home-Brewed Kombucha", desc: "Alive. Possibly sentient.", price: 3,
603 apply: function (s) { if (chance(0.25)) { s.vibes = clamp(s.vibes - 6, 0, CAP.vibes); return "That batch had turned. -6 Vibes. Never trust the cloudy one."; } s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return "+5 Vibes. Gut flora: thriving."; } },
604 { id: "incense", who: "Nag Champa Nick", name: "A Bundle of Incense", desc: "Masks the van smell. Barely.", price: 2,
605 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return "+3 Vibes. The van now smells like a sandalwood fire, which is an upgrade."; } },
606 { id: "poncho", who: "The Poncho Lady", name: "A Handwoven Poncho", desc: "For the biblical downpours ahead.", price: 7,
607 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); s.cred = clamp(s.cred + 3, 0, 100); return "+3 Vibes, +3 Lot Cred. You look like a very warm burrito."; } },
608 { id: "masters", who: "Reel Deal Ray", name: "A Reel of Second-Gen Masters", desc: "Trade fodder. Pristine sound.", price: 12,
609 apply: function (s) { s.tapes += 1; return "+1 Tape for the case. Ray never lowers his prices and never has to."; } },
610 { id: "bracelet", who: "A Very Small Child", name: "A Friendship Bracelet", desc: "Hand-made. Priceless. Two dollars.", price: 2,
611 apply: function (s) { s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return "+5 Vibes. You will wear it until it disintegrates."; } }
612 ];
613
614 var HUSTLES = [
615 { id: "grill", label: "Grill up grilled cheeses ($ from the pan)", need: function (s) { return s.grub >= 2; },
616 run: function (s) { var got = rnd(6, 16); s.cash += got; s.grub = clamp(s.grub - 2, 0, CAP.grub); s.vibes = clamp(s.vibes - 2, 0, CAP.vibes);
617 return { line: "You flip cheeses till the propane sputters. +" + money(got) + ", -2 Grub.", good: true }; } },
618 { id: "sell", label: "Sell some bootleg tapes off the blanket", need: function (s) { return s.tapes >= 1; },
619 run: function (s) { var n = Math.min(s.tapes, rnd(1, 2)); var got = n * rnd(5, 11); s.tapes -= n; s.cash += got;
620 var lost = tapersCode(s, n);
621 return { line: "Sold " + n + " tape" + (n > 1 ? "s" : "") + " to a hungry collector. +" + money(got) + "." + lost, good: true }; } },
622 { id: "spin", label: "Spin fire / drum / generally be a spectacle", need: function () { return true; },
623 run: function (s) { var cr = rnd(3, 9); s.cred = clamp(s.cred + cr, 0, 100); s.vibes = clamp(s.vibes + rnd(0, 4), 0, CAP.vibes);
624 var tip = rnd(2, 7); s.cash += tip; return { line: "The lot loves it. +" + cr + " Lot Cred, +" + money(tip) + " in the hat.", good: true }; } },
625 { id: "hunt", label: "Hunt for a taper who needs a patch cable", need: function () { return true; },
626 run: function (s) { if (chance(0.5)) { s.blanks += rnd(1, 3); return { line: "Traded know-how for blanks. +tapes to record on.", good: true }; }
627 s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "Struck out. Wandered the lot for hours. -3 Vibes.", good: false }; } },
628 { id: "veggie", label: "Cook up veggie burritos to sell", need: function (s) { return s.grub >= 3; },
629 run: function (s) { var got = rnd(8, 18); s.cash += got; s.grub = clamp(s.grub - 3, 0, CAP.grub); return { line: "The burrito line wraps around the van. +" + money(got) + ", -3 Grub.", good: true }; } },
630 { id: "poster", label: "Sell hand-drawn show posters", need: function () { return true; },
631 run: function (s) { if (chance(0.6)) { var got = rnd(4, 12); s.cash += got; s.cred = clamp(s.cred + 2, 0, 100); return { line: "Your bootleg art moves. +" + money(got) + ", +2 Lot Cred.", good: true }; } s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "Nobody's buying today, and the markers ran dry. -2 Vibes.", good: false }; } },
632 { id: "facepaint", label: "Face-paint the lot kids", need: function () { return true; },
633 run: function (s) { var got = rnd(3, 10); s.cash += got; s.cred = clamp(s.cred + 2, 0, 100); return { line: "A dozen tiny lightning bolts later: +" + money(got) + ", +2 Lot Cred.", good: true }; } },
634 { id: "tarot", label: "Read tarot at a folding table", need: function () { return true; },
635 run: function (s) { if (chance(0.6)) { var got = rnd(5, 13); s.cash += got; s.cred = clamp(s.cred + 3, 0, 100); return { line: "You pull The Fool for everyone. Nobody complains. +" + money(got) + ", +3 Lot Cred.", good: true }; } s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "You drew Death for a nervous first-timer. Awkward. -2 Vibes.", good: false }; } },
636 { id: "busk", label: "Busk with your guitar for tips", need: function () { return true; },
637 run: function (s) { var got = rnd(2, 9); s.cash += got; s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "Three chords and the truth. +" + money(got) + " in the case, +3 Vibes.", good: true }; } },
638 { id: "shuttle", label: "Run heads to the venue in your van", need: function (s) { return s.gas >= 1; },
639 run: function (s) { var got = rnd(6, 14); s.cash += got; s.gas = clamp(s.gas - 1, 0, CAP.gas); s.van = clamp(s.van - rnd(1, 3), 0, 100); return { line: "Eleven people, six seatbelts. +" + money(got) + ", -1 Gas, some wear.", good: true }; } },
640 { id: "water", label: "Sell cold waters out of the cooler", need: function () { return true; },
641 run: function (s) { var got = rnd(3, 9); s.cash += got; return { line: "\"Ice cold water, dollar a bottle!\" +" + money(got) + ".", good: true }; } },
642 { id: "jewelry", label: "Sell hemp jewelry off the blanket", need: function () { return true; },
643 run: function (s) { if (chance(0.55)) { var got = rnd(4, 11); s.cash += got; s.cred = clamp(s.cred + 2, 0, 100); return { line: "Anklets move fast in sandal weather. +" + money(got) + ", +2 Lot Cred.", good: true }; } s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "Slow day, sore fingers. -2 Vibes.", good: false }; } },
644 { id: "massage", label: "Offer \"energy work\" by donation", need: function () { return true; },
645 run: function (s) { if (chance(0.5)) { var got = rnd(5, 12); s.cash += got; s.cred = clamp(s.cred + 2, 0, 100); return { line: "You wave your hands near a stranger's spine. They tip well. +" + money(got) + ", +2 Lot Cred.", good: true }; } s.cred = clamp(s.cred - 3, 0, 100); return { line: "Someone asks if you're actually licensed. You are not. -3 Lot Cred.", good: false }; } },
646 { id: "dishwash", label: "Wash dishes for a food cart", need: function () { return true; },
647 run: function (s) { var got = rnd(4, 9); s.cash += got; s.grub = clamp(s.grub + 3, 0, CAP.grub); s.vibes = clamp(s.vibes - 1, 0, CAP.vibes); return { line: "Honest work, pruny hands. +" + money(got) + ", +3 Grub (they feed you), -1 Vibes.", good: true }; } }
648 ];
649
650 // Random road events. apply(s) mutates and returns { line, good }.
651 var EVENTS = [
652 { id: "nitrous", title: "THE NITROUS MAFIA ROLLS UP",
653 text: "A wall of hissing tanks sets up two spaces down. The vibe of the whole lot drops three notches. {deb} says they \"kind of know those guys.\"",
654 choices: [
655 { label: "Move the van to the family-camping side", cost: { gas: 2 },
656 apply: function (s) { s.gas = clamp(s.gas - 2, 0, CAP.gas); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "Peace and quiet. -2 Gas, +3 Vibes.", good: true }; } },
657 { label: "Rally the neighbors into a drum circle to push them out",
658 apply: function (s) { if (chance(0.6)) { s.cred = clamp(s.cred + 8, 0, 100); return { line: "It works! The tanks retreat. +8 Lot Cred.", good: true }; } s.vibes = clamp(s.vibes - 6, 0, CAP.vibes); return { line: "The drum circle becomes THEIR drum circle. -6 Vibes.", good: false }; } },
659 { label: "Mind your business and tape the tuning",
660 apply: function (s) { s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "You keep to yourself, ears ringing. -4 Vibes.", good: false }; } }
661 ] },
662 { id: "miracle_gift", title: "A MIRACLE, UNBIDDEN",
663 text: "A stranger with kind eyes presses something into your hand, says \"the universe provides,\" and melts into the crowd. It's a real ticket.",
664 choices: [ { label: "Whoa. Thank you, universe.",
665 apply: function (s) { s.hasTicket = true; s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); return { line: "You've got a golden ticket tonight. +6 Vibes.", good: true }; } } ] },
666 { id: "flat", title: "THWMP-THWMP-THWMP",
667 text: "A tire lets go on the interstate. {randy} insists they can change it \"with vibes alone.\"",
668 choices: [
669 { label: "Pay a passing trucker to help", cost: { cash: 15 },
670 apply: function (s) { if (s.cash < 15) { s.van = clamp(s.van - 10, 0, 100); return { line: "No cash. You limp along on the donut. -10 Van.", good: false }; } s.cash -= 15; return { line: "Fixed in ten minutes. -" + money(15) + ".", good: false }; } },
671 { label: "Let {randy} try (what could go wrong)",
672 apply: function (s) { if (chance(0.5)) return { line: "They... actually did it? Nobody speaks of it again.", good: true }; s.van = clamp(s.van - 14, 0, 100); s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "The jack slips. -14 Van, -4 Vibes.", good: false }; } }
673 ] },
674 { id: "snack", needs: "doses", title: "{doses} OFFERS YOU A SNACK",
675 text: "\"It's just a granola bar,\" says the one soul whose entire reputation is the opposite of that sentence.",
676 choices: [
677 { label: "Eat it (you trust him, for some reason)",
678 apply: function (s) { if (s.doseImmune) return { line: "You're already there. It's a nice granola bar, honestly. +2 Vibes.", good: true }; if (chance(0.5)) { s.vibes = clamp(s.vibes + 14, 0, CAP.vibes); return { line: "Colors. Music. God. +14 Vibes.", good: true }; } s.vibes = clamp(s.vibes - 16, 0, CAP.vibes); return { line: "You spend six hours convinced you're a houseplant. -16 Vibes.", good: false }; } },
679 { label: "Politely decline",
680 apply: function (s) { return { line: "Wise. {randy} eats it instead. {randy} is fine. {randy} is great, actually.", good: true }; } }
681 ] },
682 { id: "narcs", title: "THE LOT IS CRAWLING WITH NARCS",
683 text: "Undercover cops in tie-dye they clearly bought this morning are working the rows. One compliments your \"totally rad stash.\"",
684 choices: [
685 { label: "Play dumb and drift away",
686 apply: function (s) { if (chance(0.7)) return { line: "You blend into the crowd. Smooth.", good: true }; s.tapes = Math.max(0, s.tapes - 1); return { line: "They toss the van and grab a tape. -1 Tape.", good: false }; } },
687 { label: "Slip 'em a few bucks to look elsewhere", cost: { cash: 20 },
688 apply: function (s) { if (s.cash < 20) return { line: "You can't afford the bribe. They linger, menacingly.", good: false }; s.cash -= 20; return { line: "\"Have a nice show.\" -" + money(20) + ".", good: false }; } }
689 ] },
690 { id: "windfall", title: "GRILLED CHEESE GOLD RUSH",
691 text: "You set up the griddle at exactly the right moment. A wave of very hungry, very agreeable people descends.",
692 choices: [ { label: "Flip cheeses like your rent depends on it",
693 apply: function (s) { var got = rnd(18, 34); s.cash += got; s.grub = clamp(s.grub - 2, 0, CAP.grub); return { line: "You clean up. +" + money(got) + ", -2 Grub.", good: true }; } } ] },
694 { id: "randy", needs: "randy", title: "{randy} HAS WANDERED OFF",
695 text: "Nobody saw them leave. Someone thinks they followed a butterfly toward the highway around dawn.",
696 choices: [
697 { label: "Spend the day tracking him down",
698 apply: function (s) { s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "Found him befriending a state trooper. -3 Vibes, crew intact.", good: true }; } },
699 { label: "The bus waits for no one",
700 apply: function (s) { loseCrewById(s, "randy"); s.vibes = clamp(s.vibes - 8, 0, CAP.vibes); return { line: "You leave without {randy}. The van is quieter. -8 Vibes.", good: false }; } }
701 ] },
702 { id: "rain", title: "BIBLICAL SPRING DOWNPOUR",
703 text: "The sky opens. The lot becomes a lake. Your tarp had ONE job.",
704 choices: [ { label: "Ride it out in the van",
705 apply: function (s) { s.grub = clamp(s.grub - 2, 0, CAP.grub); s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); if (chance(0.3)) { s.blanks = Math.max(0, s.blanks - 2); return { line: "Some blanks got soaked. -2 blanks, -3 Vibes.", good: false }; } return { line: "You stay mostly dry. -2 Grub, -3 Vibes.", good: false }; } } ] },
706 { id: "drums", title: "THE DRUM CIRCLE ACHIEVES LIFTOFF",
707 text: "A hundred strangers lock into one impossible groove at 2 a.m. For a moment, everything makes sense.",
708 choices: [ { label: "Surrender to the rhythm",
709 apply: function (s) { s.vibes = clamp(s.vibes + 12, 0, CAP.vibes); s.cred = clamp(s.cred + 4, 0, 100); return { line: "+12 Vibes, +4 Lot Cred. You are one with the beat.", good: true }; } } ] },
710 { id: "raretape", title: "A HOLY GRAIL ON A BLANKET",
711 text: "A quiet guy is trading a pristine reel of a show that, by all accounts, doesn't exist. He'll take cash or your best blanks.",
712 choices: [
713 { label: "Buy it with cash", cost: { cash: 25 },
714 apply: function (s) { if (s.cash < 25) return { line: "You're short. He nods, understanding, and packs up.", good: false }; s.cash -= 25; s.tapes += 1; s.cred = clamp(s.cred + 6, 0, 100); return { line: "You own a legend now. +1 Tape, +6 Lot Cred, -" + money(25) + ".", good: true }; } },
715 { label: "Trade blanks for it", cost: { blanks: 4 },
716 apply: function (s) { if (s.blanks < 4) return { line: "Not enough blanks. He shrugs and rolls on.", good: false }; s.blanks -= 4; s.tapes += 1; return { line: "Fair trade. +1 Tape, -4 blanks.", good: true }; } }
717 ] },
718 { id: "siphon", title: "SOMEBODY SIPHONED YOUR GAS",
719 text: "You wake to a puddle and a length of garden hose. Classy.",
720 choices: [ { label: "Sigh. Deeply.",
721 apply: function (s) { s.gas = clamp(s.gas - 3, 0, CAP.gas); return { line: "-3 Gas. The lot is a village and villages have goblins.", good: false }; } } ] },
722 { id: "hitch", title: "A HITCHHIKER WITH A THUMB AND A STORY",
723 text: "A grinning kid at the on-ramp holds a sign reading \"ANYWHERE, MAN.\" {deb} likes his energy.",
724 choices: [
725 { label: "Pick him up",
726 apply: function (s) { s.cred = clamp(s.cred + 5, 0, 100); s.grub = clamp(s.grub - 2, 0, CAP.grub); return { line: "Good stories, knows a shortcut. +5 Lot Cred, -2 Grub (he's hungry).", good: true }; } },
727 { label: "Wave and drive on",
728 apply: function (s) { return { line: "He gives you a peace sign. You feel a little bad. But you make time.", good: true }; } }
729 ] },
730 { id: "brownacid", title: "\"THE BROWN ACID IS NOT SPECIFICALLY TOO GOOD\"",
731 text: "The stage announcer's warning echoes across the lot. Someone offers you exactly that.",
732 choices: [
733 { label: "Heed the warning",
734 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "You listen to the nice announcer man. +3 Vibes (smugness).", good: true }; } },
735 { label: "You've made worse decisions",
736 apply: function (s) { if (s.doseImmune) return { line: "Tuesday, for you. +4 Vibes.", good: true }; s.vibes = clamp(s.vibes - 18, 0, CAP.vibes); return { line: "The announcer was RIGHT. -18 Vibes.", good: false }; } }
737 ] },
738 { id: "overheat", title: "THE BUS IS RUNNING HOT",
739 text: "Steam curls from under the hood on a long grade. That's probably nothing.",
740 choices: [
741 { label: "Pull over and let her cool",
742 apply: function (s) { s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "You lose an hour but save the engine. -2 Vibes.", good: true }; } },
743 { label: "She'll make it (she will not)",
744 apply: function (s) { s.van = clamp(s.van - 16, 0, 100); return { line: "A gasket lets go. -16 Van. Told you.", good: false }; } }
745 ] },
746 { id: "family", title: "THE TOUR FAMILY ADOPTS YOU",
747 text: "A crew that's been out since '72 waves you into their camp, ladles you some stew, and asks nothing in return.",
748 choices: [ { label: "Break bread with the family",
749 apply: function (s) { s.grub = clamp(s.grub + 6, 0, CAP.grub); s.vibes = clamp(s.vibes + 8, 0, CAP.vibes); s.cred = clamp(s.cred + 6, 0, 100); return { line: "+6 Grub, +8 Vibes, +6 Lot Cred. This is what it's all about.", good: true }; } } ] },
750 { id: "poker", title: "A SETBREAK POKER GAME",
751 text: "In the back of a school bus, a low-stakes card game is going. The pot is crumpled ones and one very confident-looking off-duty veterinarian.",
752 choices: [
753 { label: "Buy in and play your hand", cost: { cash: 10 },
754 apply: function (s) { if (s.cash < 10) return { line: "You're short the buy-in. They wave you off, kindly.", good: false }; s.cash -= 10; if (chance(0.45)) { var won = rnd(18, 40); s.cash += won; s.cred = clamp(s.cred + 3, 0, 100); return { line: "You read the vet like a setlist. +" + money(won) + ", +3 Lot Cred.", good: true }; } return { line: "The vet had a boat. Of course he did. -" + money(10) + ".", good: false }; } },
755 { label: "Just watch and pass the pipe",
756 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "You kibitz from the cheap seats. +3 Vibes.", good: true }; } }
757 ] },
758 { id: "nostart", title: "THE BUS WON'T TURN OVER",
759 text: "Dead click in the morning cold. {doses} suggests \"talking to it.\" {deb} suggests a hill.",
760 choices: [
761 { label: "Push-start it down the grade",
762 apply: function (s) { if (chance(0.6)) { s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "It coughs to life on the second try. -2 Vibes and some sweat.", good: true }; } s.van = clamp(s.van - 8, 0, 100); s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "It rolls into a ditch first. -8 Van, -4 Vibes.", good: false }; } },
763 { label: "Flag down a jump from a fellow head", cost: { cash: 5 },
764 apply: function (s) { if (s.cash < 5) { s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "Nobody's got cables AND gas money. You wait it out. -3 Vibes.", good: false }; } s.cash -= 5; s.cred = clamp(s.cred + 2, 0, 100); return { line: "A van full of kids from Jersey hooks you up. -" + money(5) + " for their trouble, +2 Lot Cred.", good: true }; } }
765 ] },
766 { id: "checkpoint", title: "STATE LINE CHECKPOINT",
767 text: "A trooper waves the whole convoy onto the shoulder. He is not smiling. He has a clipboard and a dog named after a president.",
768 choices: [
769 { label: "Be polite, be boring, be gone",
770 apply: function (s) { if (chance(0.7)) return { line: "\"Drive safe now.\" You exhale a mile later.", good: true }; s.tapes = Math.max(0, s.tapes - 1); s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "The dog takes an interest in the tape case. -1 Tape, -4 Vibes.", good: false }; } },
771 { label: "Send {deb} up to charm him",
772 apply: function (s) { if (chance(0.65)) { s.cred = clamp(s.cred + 4, 0, 100); return { line: "{deb} 'knows a guy' who turns out to be the trooper's cousin. Small world. +4 Lot Cred.", good: true }; } s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "Turns out they do NOT know that guy. Awkward. -3 Vibes.", good: false }; } }
773 ] },
774 { id: "tourists", title: "A BUSLOAD OF GERMAN TAPEHEADS",
775 text: "A tour bus of impossibly polite German fans pulls alongside. They have deutschmarks, immaculate gear, and a bottomless hunger for American soundboards.",
776 choices: [
777 { label: "Trade them a tape for cash", cost: { tapes: 1 },
778 apply: function (s) { if (s.tapes < 1) return { line: "Your case is empty. They bow, disappointed.", good: false }; s.tapes -= 1; var got = rnd(14, 24); s.cash += got; s.cred = clamp(s.cred + 3, 0, 100); return { line: "\"Wunderbar!\" +" + money(got) + ", +3 Lot Cred, -1 Tape.", good: true }; } },
779 { label: "Swap stories and blanks instead",
780 apply: function (s) { s.blanks += rnd(2, 4); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "They gift you a stack of pristine German blanks. +blanks, +4 Vibes.", good: true }; } }
781 ] },
782 { id: "dawn", title: "GRATEFUL DAWN",
783 text: "You drive straight through and the sun comes up pink over a field of somebody's cows. Nobody in the van says anything. Nobody has to.",
784 choices: [ { label: "Pull over and watch it come up",
785 apply: function (s) { s.vibes = clamp(s.vibes + 10, 0, CAP.vibes); return { line: "+10 Vibes. This is why you came.", good: true }; } } ] },
786 { id: "clams", title: "THE PARKING-LOT CLAM SHACK",
787 text: "Somebody has set up a folding table advertising \"FRESH\" clams. In Ohio. In May. The sign is very confident.",
788 choices: [
789 { label: "You only live once",
790 apply: function (s) { if (ironGut(s)) { s.grub = clamp(s.grub + 6, 0, CAP.grub); return { line: "Your gut is a fortress. +6 Grub, no notes.", good: true }; } if (chance(0.5)) { s.grub = clamp(s.grub + 6, 0, CAP.grub); return { line: "Somehow, delicious. +6 Grub.", good: true }; } s.vibes = clamp(s.vibes - 12, 0, CAP.vibes); s.grub = clamp(s.grub - 2, 0, CAP.grub); return { line: "You spend the next show very close to the restrooms. -12 Vibes, -2 Grub.", good: false }; } },
791 { label: "Hard pass",
792 apply: function (s) { return { line: "The one decision on this whole tour nobody will ever second-guess.", good: true }; } }
793 ] },
794 { id: "blowout", title: "BLOWOUT ON THE GRADE",
795 text: "A rear tire detonates like a shotgun at 60 mph. The van fishtails, then settles. Everyone's very awake now.",
796 choices: [
797 { label: "Handle it (calmly, hopefully)",
798 apply: function (s) { if (hasItem(s, "spare_tire")) { takeItem(s, "spare_tire"); return { line: "Good thing you packed the full-size spare. Swapped in ten minutes, no damage. (Used your spare.)", good: true }; } s.van = clamp(s.van - 14, 0, 100); s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "You limp in on the undersized donut. -14 Van, -4 Vibes. Buy a real spare next time.", good: false }; } }
799 ] },
800 { id: "freebox", title: "THE FREE BOX",
801 text: "Every lot has one: a cardboard box of donated everything. Somebody's poncho, three left sandals, and -- wait, is that useful?",
802 choices: [
803 { label: "Dig through it",
804 apply: function (s) { if (chance(0.5)) { var part = pick(["disco_ball", "cb_radio", "roof_rack"]); s.stash.push(part); return { line: "You fish out a " + gearName(part) + ". Into the stash it goes.", good: true }; } s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return { line: "Mostly single sandals and damp zines, but you find a decent hat. +2 Vibes.", good: true }; } },
805 { label: "Leave it for the next head",
806 apply: function (s) { s.cred = clamp(s.cred + 3, 0, 100); return { line: "You add a spare blanket to it instead. The lot notices. +3 Lot Cred.", good: true }; } }
807 ] },
808 { id: "lotwedding", title: "A WEDDING IN THE LOT",
809 text: "Two heads are getting married by a guy ordained \"by the internet, which won't exist for twenty years.\" They need witnesses. And cake. Mostly cake.",
810 choices: [
811 { label: "Cater it with grilled cheese",
812 apply: function (s) { if (s.grub < 3) return { line: "You're too low on grub to feed a wedding. You clap instead.", good: false }; s.grub = clamp(s.grub - 3, 0, CAP.grub); s.cred = clamp(s.cred + 7, 0, 100); s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); return { line: "Best reception on Shakedown Street. -3 Grub, +7 Lot Cred, +6 Vibes.", good: true }; } },
813 { label: "Just dance at the reception",
814 apply: function (s) { s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return { line: "You spin the bride's grandmother around. +5 Vibes.", good: true }; } }
815 ] },
816 { id: "vanenvy", title: "THE MOST TRICKED-OUT VAN YOU'VE EVER SEEN",
817 text: "Solar panels. A rooftop deck. A hand-lettered sign: \"ASK ME ABOUT MY AUX FUEL TANK.\" The owner is delighted you asked.",
818 choices: [
819 { label: "Take the grand tour and take notes",
820 apply: function (s) { s.flags.vanTips = true; s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "You learn a few tricks. Van parts feel a little cheaper next time you tinker. +3 Vibes.", good: true }; } },
821 { label: "Trade him a tape for a spare part", cost: { tapes: 1 },
822 apply: function (s) { if (s.tapes < 1) return { line: "Nothing to trade. He waves you off cheerfully.", good: false }; s.tapes -= 1; var part = pick(["hd_shocks", "big_tank", "pa_rig"]); s.stash.push(part); return { line: "He hands you a " + gearName(part) + " for your rig. -1 Tape.", good: true }; } }
823 ] },
824 { id: "taperfeud", title: "A TAPER SECTION TERRITORY WAR",
825 text: "Two tapers are in a slow, hissing argument over one square foot of prime patch space. Both look to you to settle it. You have no authority whatsoever.",
826 choices: [
827 { label: "Broker a stereo-pair compromise",
828 apply: function (s) { if (chance(0.7)) { s.cred = clamp(s.cred + 6, 0, 100); s.blanks += 1; return { line: "Peace in our time. Both slip you a blank in thanks. +6 Lot Cred, +1 blank.", good: true }; } s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "They unite against you, the true enemy. -3 Vibes.", good: false }; } },
829 { label: "Quietly set up between them",
830 apply: function (s) { s.flags.goodSpot = true; return { line: "You take the disputed square foot while they bicker. Sneaky. Your next tape gets a boost.", good: true }; } }
831 ] },
832 { id: "mysteryspot", title: "MYSTERY SPOT: 1 MILE",
833 text: "The signs have been taunting you for three states. Water flows uphill! Brooms stand on end! {randy} is already crying with excitement.",
834 choices: [
835 { label: "Pull off and see the gravity anomaly", cost: { cash: 6 },
836 apply: function (s) { if (s.cash < 6) return { line: "Admission is six bucks you don't have. {randy} is devastated.", good: false }; s.cash -= 6; s.vibes = clamp(s.vibes + 9, 0, CAP.vibes); s.day += 1; return { line: "It's a tilted shack and it RULES. -" + money(6) + ", +9 Vibes, lost a day.", good: true }; } },
837 { label: "Stay on schedule (be strong)",
838 apply: function (s) { s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "You drive past. Something in you dies a little. -3 Vibes.", good: false }; } }
839 ] },
840 { id: "wallet", title: "A FAT WALLET IN THE GRASS",
841 text: "It's stuffed with cash and a driver's license for one \"Chad Prendergast III.\" Chad, judging by the photo, is going to be fine either way.",
842 choices: [
843 { label: "Track down Chad and return it",
844 apply: function (s) { s.cred = clamp(s.cred + 10, 0, 100); s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); if (chance(0.5)) { s.cash += 10; return { line: "Chad tips you ten and a war story. +10 Lot Cred, +5 Vibes, +" + money(10) + ".", good: true }; } return { line: "Chad says \"righteous\" and nothing else. Worth it. +10 Lot Cred, +5 Vibes.", good: true }; } },
845 { label: "Finders keepers",
846 apply: function (s) { var got = rnd(20, 45); s.cash += got; s.cred = clamp(s.cred - 8, 0, 100); s.vibes = clamp(s.vibes - 5, 0, CAP.vibes); return { line: "You pocket +" + money(got) + " but the karma settles in like a fog. -8 Lot Cred, -5 Vibes.", good: false }; } }
847 ] },
848 { id: "cooler", title: "THE BOTTOMLESS COOLER",
849 text: "A grinning couple from Kentucky waves you over to a cooler that, by all evidence, cannot run out of cold drinks and sandwiches.",
850 choices: [ { label: "Accept their endless hospitality",
851 apply: function (s) { s.grub = clamp(s.grub + 8, 0, CAP.grub); s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return { line: "+8 Grub, +5 Vibes. You will never learn their names and you will never forget them.", good: true }; } } ] },
852 { id: "hackysack", title: "AN ETERNAL HACKY-SACK CIRCLE",
853 text: "The sack has not touched the ground in, by local legend, eleven days. A gap opens in the circle. It is clearly meant for you.",
854 choices: [
855 { label: "Step in and keep it alive",
856 apply: function (s) { if (chance(0.55)) { s.cred = clamp(s.cred + 5, 0, 100); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "Three sick stalls and a behind-the-back save. +5 Lot Cred, +4 Vibes.", good: true }; } s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "You end an eleven-day streak in four seconds. The circle mourns. -2 Vibes.", good: false }; } },
857 { label: "Bow respectfully and move on",
858 apply: function (s) { return { line: "You know your limits. The sack sails on. Balance is preserved.", good: true }; } }
859 ] },
860 { id: "payphone", title: "A PAY PHONE, AND A QUARTER",
861 text: "You find a working pay phone at a dead gas station. It occurs to you that people at home might wonder if you're alive.",
862 choices: [
863 { label: "Call home",
864 apply: function (s) { if (s.role === "runaway") { s.vibes = clamp(s.vibes - 6, 0, CAP.vibes); s.cred = clamp(s.cred + 4, 0, 100); return { line: "Your mom picks up on the first ring. It's complicated. -6 Vibes, but you feel a little older. +4 Lot Cred.", good: false }; } s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); return { line: "\"I'm fine, I'm great, I'll call from Buffalo.\" +6 Vibes.", good: true }; } },
865 { label: "Let it ring for someone else",
866 apply: function (s) { return { line: "You leave the quarter in the slot for the next wanderer. Small mercy.", good: true }; } }
867 ] },
868 { id: "guru", title: "THE GAS-STATION ORACLE",
869 text: "The attendant at a lonely pump looks at your van, then at you, and says something so profound you'll think about it for years. Or he's just tired.",
870 choices: [ { label: "Absorb the wisdom",
871 apply: function (s) { var w = pick([8, -0, 6, 4]); s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); s.cred = clamp(s.cred + 2, 0, 100); return { line: "\"The road is just the parking lot between two shows, man.\" +6 Vibes, +2 Lot Cred.", good: true }; } } ] },
872 { id: "shirtbust", title: "THE BOOTLEG SHIRT BUST",
873 text: "Fed agents descend on a guy selling unlicensed stealie shirts three vans down. It is the least threatening raid in law-enforcement history.",
874 choices: [
875 { label: "Quietly buy his last shirt before they reach him", cost: { cash: 8 },
876 apply: function (s) { if (s.cash < 8) return { line: "You're too broke to be a hero. The shirts are confiscated.", good: false }; s.cash -= 8; s.cred = clamp(s.cred + 6, 0, 100); return { line: "You wear it proudly. It is technically evidence. -" + money(8) + ", +6 Lot Cred.", good: true }; } },
877 { label: "Suddenly remember somewhere else to be",
878 apply: function (s) { s.vibes = clamp(s.vibes - 1, 0, CAP.vibes); return { line: "You evaporate. Not your circus, not your stealie monkeys. -1 Vibes.", good: true }; } }
879 ] },
880 { id: "detour", title: "A RAINBOW GATHERING, JUST OFF THE EXIT",
881 text: "A hand-painted arrow promises a week of free food, drum circles, and \"no leaders, only vibes\" down a dirt road. The tour, meanwhile, waits.",
882 choices: [
883 { label: "Just for one night...",
884 apply: function (s) { s.vibes = clamp(s.vibes + 12, 0, CAP.vibes); s.grub = clamp(s.grub + 4, 0, CAP.grub); s.day += 2; s.gas = clamp(s.gas - 1, 0, CAP.gas); return { line: "You lose two days and find something. +12 Vibes, +4 Grub, -2 days.", good: true }; } },
885 { label: "Wave and stay on the interstate",
886 apply: function (s) { s.cred = clamp(s.cred + 2, 0, 100); return { line: "The show must go on, and so must you. +2 Lot Cred for discipline.", good: true }; } }
887 ] },
888 { id: "batteryradio", title: "THE RADIO DIES",
889 text: "The 8-track finally gives up mid-'Scarlet Begonias.' The silence in the van is enormous and somehow accusatory.",
890 choices: [
891 { label: "Sing the rest yourselves",
892 apply: function (s) { s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return { line: "Four-part harmony, zero correct notes, total joy. +5 Vibes.", good: true }; } },
893 { label: "Drive in holy silence",
894 apply: function (s) { s.vibes = clamp(s.vibes - 2, 0, CAP.vibes); return { line: "It's a long, quiet stretch of your own thoughts. -2 Vibes.", good: false }; } }
895 ] },
896 { id: "cornfield", needs: "deb", title: "{deb}'S FAMOUS SHORTCUT",
897 text: "\"Trust me, this cuts off an HOUR.\" Two hours later you are on a gravel road between two identical cornfields, being watched by a single, judgmental cow.",
898 choices: [
899 { label: "Keep following {deb}'s gut",
900 apply: function (s) { if (chance(0.4)) { s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "It... actually works? You hit the highway ahead of everyone. +4 Vibes.", good: true }; } s.gas = clamp(s.gas - 2, 0, CAP.gas); s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "Dead end at a grain silo. Turn around. -2 Gas, -4 Vibes.", good: false }; } },
901 { label: "Mutiny and use the map",
902 apply: function (s) { s.vibes = clamp(s.vibes - 1, 0, CAP.vibes); return { line: "{deb} sulks the whole way but the map was right. -1 Vibes, dignity intact.", good: true }; } }
903 ] },
904 { id: "puppy", title: "A LOT DOG HAS CHOSEN YOU",
905 text: "A scruffy, joyful mutt with a bandana has decided your van is home now. He has no collar and infinite gratitude.",
906 choices: [
907 { label: "Welcome him aboard",
908 apply: function (s) { s.crew.push({ name: "Scoop the Lot Dog", trait: "morale officer", ok: true }); s.vibes = clamp(s.vibes + 8, 0, CAP.vibes); s.grub = clamp(s.grub - 2, 0, CAP.grub); return { line: "Scoop is crew now. +8 Vibes, -2 Grub (he eats). Worth it. Obviously.", good: true }; } },
909 { label: "Find his people",
910 apply: function (s) { s.cred = clamp(s.cred + 4, 0, 100); return { line: "You reunite him with a weeping teenager two rows over. +4 Lot Cred.", good: true }; } }
911 ] },
912 { id: "statefair", title: "A STATE FAIR, IN FULL SWING",
913 text: "Ferris wheel, funnel cake, a prize hog named Governor. It is aggressively wholesome and smells incredible.",
914 choices: [
915 { label: "Blow a few bucks on funnel cake", cost: { cash: 5 },
916 apply: function (s) { if (s.cash < 5) return { line: "You press your face to the fence, penniless. The hog judges you.", good: false }; s.cash -= 5; s.grub = clamp(s.grub + 4, 0, CAP.grub); s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return { line: "Powdered sugar on everything you own. -" + money(5) + ", +4 Grub, +5 Vibes.", good: true }; } },
917 { label: "Admire it from the highway",
918 apply: function (s) { return { line: "You keep rolling. The Ferris wheel shrinks in the mirror, then the memory.", good: true }; } }
919 ] },
920 { id: "hotel_ice", title: "THE HOWARD JOHNSON'S ICE MACHINE",
921 text: "The crew that got a real motel room tonight has propped the side door. Free ice, a real bathroom, and a color TV showing static.",
922 choices: [
923 { label: "Sneak in for showers and ice",
924 apply: function (s) { if (chance(0.8)) { s.vibes = clamp(s.vibes + 7, 0, CAP.vibes); return { line: "Hot water! A towel! You emerge reborn. +7 Vibes.", good: true }; } s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "The night manager escorts you out mid-lather. -3 Vibes.", good: false }; } },
925 { label: "Just fill the cooler and go",
926 apply: function (s) { s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return { line: "Ice-cold drinks for the whole van. +2 Vibes.", good: true }; } }
927 ] },
928 { id: "philosopher", title: "THE PROFESSOR HAS A THEORY",
929 text: "Somebody in the van will not stop connecting the second-set jam to the collapse of Bretton Woods. Even for this crowd, it's a lot.",
930 choices: [
931 { label: "Engage with the theory (God help you)",
932 apply: function (s) { if (s.role === "professor") { s.cred = clamp(s.cred + 6, 0, 100); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "For once you've found your people, and they're right. +6 Lot Cred, +4 Vibes.", good: true }; } s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "Ninety minutes you will never get back. -4 Vibes.", good: false }; } },
933 { label: "\"Cool. Anyway.\"",
934 apply: function (s) { s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return { line: "You change the subject to snacks. The van exhales. +2 Vibes.", good: true }; } }
935 ] },
936 { id: "toll", title: "THE TURNPIKE TOLL, AND NO CHANGE",
937 text: "The booth arm is down, a line is forming behind you, and the whole van is turning out pockets that contain lint, guitar picks, and one very sad raisin.",
938 choices: [
939 { label: "Scrape together the toll", cost: { cash: 3 },
940 apply: function (s) { if (s.cash < 3) { s.cred = clamp(s.cred - 2, 0, 100); return { line: "The tollbooth lady waves you through with a look. -2 Lot Cred (from shame).", good: false }; } s.cash -= 3; return { line: "Exact change, produced from six people's pockets. -" + money(3) + ".", good: true }; } },
941 { label: "Sweet-talk the tollbooth attendant",
942 apply: function (s) { if (chance(0.5)) { s.cred = clamp(s.cred + 3, 0, 100); return { line: "She's a head too, off the clock. Waves you through. +3 Lot Cred.", good: true }; } s.cash = Math.max(0, s.cash - 3); return { line: "She is emphatically not a head. Pay up. -" + money(3) + ".", good: false }; } }
943 ] },
944 { id: "sunburn", title: "NOBODY BROUGHT SUNSCREEN",
945 text: "Three days of lot in the open sun and the whole crew is the color of a stop sign. {randy} claims they \"tan through the pain.\"",
946 choices: [
947 { label: "Rig some shade and lay low",
948 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "Tarps, umbrellas, and one stolen beach chair. +3 Vibes.", good: true }; } },
949 { label: "Tough it out",
950 apply: function (s) { s.vibes = clamp(s.vibes - 5, 0, CAP.vibes); return { line: "You are all extremely crispy in every sense. -5 Vibes.", good: false }; } }
951 ] },
952 { id: "highwayangel", title: "A STRANGER PAYS YOUR GAS",
953 text: "You come up short at the pump. Before you can panic, a trucker two islands over just... covers it. \"Somebody did it for me once,\" he shrugs.",
954 choices: [ { label: "Pay it forward down the line",
955 apply: function (s) { s.gas = clamp(s.gas + 3, 0, CAP.gas); s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); s.cred = clamp(s.cred + 3, 0, 100); return { line: "+3 Gas, +6 Vibes, +3 Lot Cred. The whole thing runs on this.", good: true }; } } ] },
956 // ---- van-mate sub-plots: the crew you actually roll with, front and center ----
957 { id: "deb_connection", needs: "deb", title: "{deb} \"KNOWS A GUY\"",
958 text: "{deb} has been on the CB for an hour. \"Okay. My guy's brother runs a garage off exit 12. He owes me from Watkins Glen. We are SET.\" The whole van has learned to hold its breath at moments like this.",
959 choices: [
960 { label: "Follow {deb}'s lead (you always do)",
961 apply: function (s) { if (!crewAlive(s) || chance(0.68)) { s.van = clamp(s.van + 16, 0, 100); s.cred = clamp(s.cred + 4, 0, 100); return { line: "The guy is real, the guy is kind, the van gets a free once-over. +16 Van, +4 Lot Cred. {deb} is insufferable about it for days.", good: true }; } s.gas = clamp(s.gas - 2, 0, CAP.gas); s.vibes = clamp(s.vibes - 3, 0, CAP.vibes); return { line: "The garage is a boarded-up husk. The guy moved to Reno. -2 Gas, -3 Vibes. {deb} swears this has never happened before.", good: false }; } },
962 { label: "Gently overrule them this once",
963 apply: function (s) { s.vibes = clamp(s.vibes - 1, 0, CAP.vibes); return { line: "{deb} sulks for a county, then admits the highway was faster. -1 Vibes, dignity intact.", good: true }; } }
964 ] },
965 { id: "randy_quest", needs: "randy", title: "{randy}'S BIG SCORE",
966 text: "{randy} vanishes at a rest stop and reappears forty minutes later, grinning, cradling something under their tie-dye. \"You are NOT going to believe what somebody left in the free box,\" they whisper.",
967 choices: [
968 { label: "See what {randy} dragged back",
969 apply: function (s) { if (chance(0.6)) { var part = pick(["disco_ball", "cb_radio", "roof_rack", "spare_tire"]); s.stash.push(part); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "It's a genuinely useful " + gearName(part) + ". {randy} is a hero. +4 Vibes, and it's in your stash.", good: true }; } s.grub = clamp(s.grub - 1, 0, CAP.grub); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "It's a single roller skate and a mood ring. Useless, delightful. -1 Grub (they traded a snack for it), +3 Vibes anyway.", good: true }; } },
970 { label: "Tell him to leave the free-box stuff",
971 apply: function (s) { s.cred = clamp(s.cred + 2, 0, 100); return { line: "You add a blanket to the free box instead. The lot notices the karma. +2 Lot Cred. {randy} pouts.", good: true }; } }
972 ] },
973 { id: "doses_kitchen", needs: "doses", title: "{doses} IS COOKING FOR THE VAN",
974 text: "\"I made a big pot of stew for everybody,\" announces the one soul in America whose cooking should come with a lawyer. The crew eyes the pot. The crew eyes you.",
975 choices: [
976 { label: "Eat it, trusting the vibes",
977 apply: function (s) { if (s.doseImmune || ironGut(s)) { s.grub = clamp(s.grub + 8, 0, CAP.grub); s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "Rock solid, actually delicious. +8 Grub, +4 Vibes. {doses} beams.", good: true }; } if (chance(0.55)) { s.grub = clamp(s.grub + 8, 0, CAP.grub); s.vibes = clamp(s.vibes + 5, 0, CAP.vibes); return { line: "Against all odds, it's the best meal in three states. +8 Grub, +5 Vibes.", good: true }; } s.grub = clamp(s.grub + 4, 0, CAP.grub); s.vibes = clamp(s.vibes - 10, 0, CAP.vibes); return { line: "There was something ELSE in the stew. The next show is a smeary watercolor. +4 Grub, -10 Vibes.", good: false }; } },
978 { label: "Feed it to the crew, watch carefully",
979 apply: function (s) { s.grub = clamp(s.grub + 5, 0, CAP.grub); s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return { line: "Everybody else is fine and weirdly serene. You had toast. +5 Grub, +2 Vibes.", good: true }; } }
980 ] },
981 { id: "crew_campfire", title: "A CAMPFIRE, AND THE WHOLE CREW",
982 text: "You pull off at a state park nobody was looking for. {deb} finds firewood, {randy} finds marshmallows, {doses} finds their harmonica. For a few hours it's just your people and the dark.",
983 choices: [
984 { label: "Stay up with the crew",
985 apply: function (s) { var v = 8 + crewAlive(s) * 2; s.vibes = clamp(s.vibes + v, 0, CAP.vibes); s.cred = clamp(s.cred + 3, 0, 100); return { line: "Everyone leaves a little more bonded than they came. +" + v + " Vibes, +3 Lot Cred. This is the crew you'd cross the country for.", good: true }; } },
986 { label: "Turn in early to save the miles",
987 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "You sleep hard and wake first. The crew grumbles but you're ahead of schedule. +3 Vibes.", good: true }; } }
988 ] },
989 { id: "crew_wobble", title: "A VAN-MATE IS HAVING A DAY",
990 text: "The road is long and somebody in the back has gone quiet in the bad way. When the vibes dip, cracks show. It could go either direction from here.",
991 choices: [
992 { label: "Pull over and actually talk it out",
993 apply: function (s) { s.vibes = clamp(s.vibes + 6, 0, CAP.vibes); s.cred = clamp(s.cred + 2, 0, 100); return { line: "You lose an hour and gain a friend back. +6 Vibes, +2 Lot Cred. Nobody gets left behind.", good: true }; } },
994 { label: "Push on and hope it passes",
995 apply: function (s) { if (s.vibes >= 55 || chance(Math.min(0.95, 0.6 * DNA.loyal))) { s.vibes = clamp(s.vibes + 2, 0, CAP.vibes); return { line: "By the next exit they're singing along again. The good vibes carried it. +2 Vibes.", good: true }; } var alive = s.crew.filter(function (c) { return c.ok; }); if (alive.length > 1) { var who = pick(alive); loseCrew(s, who.name); s.vibes = clamp(s.vibes - 6, 0, CAP.vibes); return { line: who.name + " asks to be let off at the next town, and means it. You lose a van-mate. -6 Vibes.", good: false }; } s.vibes = clamp(s.vibes - 4, 0, CAP.vibes); return { line: "It's a long, silent stretch, but the last of you holds together. -4 Vibes.", good: false }; } }
996 ] },
997 // ---- sub-plots for the newcomers (only fire when that van-mate is aboard) ----
998 { id: "rita_source", needs: "rita", title: "{rita} FOUND THE SOUNDBOARD FEED",
999 text: "{rita} presses a headphone to one ear, goes very still, and whispers: \"That's a patch off the actual board. Two rows up. If we move now, we get it clean.\"",
1000 choices: [
1001 { label: "Trust their ears and set up fast",
1002 apply: function (s) { s.flags.goodSpot = true; s.cred = clamp(s.cred + 3, 0, 100); return { line: "{rita} threads you into a pristine feed. +3 Lot Cred, and your next recording comes out noticeably cleaner.", good: true }; } },
1003 { label: "Stay put -- don't push your luck",
1004 apply: function (s) { s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "You hold your spot and just listen with them. +3 Vibes. Some tapes only play in memory.", good: true }; } }
1005 ] },
1006 { id: "hitch_backroad", needs: "hitch", title: "{hitch} KNOWS A BACKROAD",
1007 text: "\"Trust me,\" says {hitch}, already pointing down an unmarked county road. \"Skips the weigh station AND the traffic. My cousin drove a hearse down here for years.\"",
1008 choices: [
1009 { label: "Take the shortcut",
1010 apply: function (s) { if (chance(0.75)) { s.gas = clamp(s.gas + 2, 0, CAP.gas); s.vibes = clamp(s.vibes + 3, 0, CAP.vibes); return { line: "{hitch} was dead right -- you save gas and hit town early. +2 Gas, +3 Vibes.", good: true }; } s.van = clamp(s.van - 4, 0, 100); return { line: "The road turns to washboard gravel for six miles. Rattly, but you make it. -4 Van.", good: false }; } },
1011 { label: "Stick to the highway",
1012 apply: function (s) { return { line: "{hitch} shrugs and tells you the hearse story anyway. It's a good story.", good: true }; } }
1013 ] },
1014 { id: "bird_wonder", needs: "bird", title: "{bird}'S FIRST REAL SHOW",
1015 text: "{bird} has never seen anything like this and it shows -- wide eyes, tears they're not embarrassed about, hands pressed to their chest. It reminds the whole van why you're out here.",
1016 choices: [
1017 { label: "Watch the show through their eyes",
1018 apply: function (s) { s.vibes = clamp(s.vibes + 8, 0, CAP.vibes); s.cred = clamp(s.cred + 2, 0, 100); return { line: "For one set you're all first-timers again. +8 Vibes, +2 Lot Cred.", good: true }; } },
1019 { label: "Show them how to score a miracle",
1020 apply: function (s) { if (!s.hasTicket) { s.hasTicket = true; return { line: "{bird}'s open face melts a stranger on the spot -- a spare ticket appears. You're in tonight.", good: true }; } s.vibes = clamp(s.vibes + 4, 0, CAP.vibes); return { line: "You teach {bird} the one-finger salute. They land a miracle in four minutes flat. +4 Vibes.", good: true }; } }
1021 ] }
1022 ];
1023
1024 // Van customization raises some caps, so CAP is recomputed from BASE_CAP whenever
1025 // parts change (see recomputeBuffs). Only one game runs at a time, so mutating the
1026 // shared CAP object per run is safe and keeps every existing clamp() honest.
1027 var BASE_CAP = { gas: 20, grub: 40, van: 100, vibes: 100 };
1028 var CAP = { gas: 20, grub: 40, van: 100, vibes: 100 };
1029
1030 // ---------- character mods (the builder's perks) ----------
1031 // apply(s) mutates ONLY numeric stats and boolean flags -- never cash -- so the
1032 // builder can re-derive a clean loadout from baseStats every time a mod is toggled.
1033 var MODS = [
1034 { id: "iron_stomach", name: "Iron Stomach", desc: "Bad clams, cursed burritos -- nothing touches you.", apply: function (s) { s.flags.ironStomach = true; } },
1035 { id: "silver_tongue", name: "Silver Tongue", desc: "You talk your way in anywhere. +12 starting Lot Cred.", apply: function (s) { s.cred = clamp(s.cred + 12, 0, 100); } },
1036 { id: "lead_foot", name: "Lead Foot", desc: "You drive like you're being chased. Legs go faster; the van pays for it.", apply: function (s) { s.flags.leadFoot = true; } },
1037 { id: "night_owl", name: "Night Owl", desc: "Your best thinking happens at 3 a.m. Your Peak hits even harder.", apply: function (s) { s.flags.nightOwl = true; } },
1038 { id: "golden_ears", name: "Golden Ears", desc: "You hear the whole mix. +8 quality on every tape you pull.", apply: function (s) { s.flags.goldenEars = true; } },
1039 { id: "pack_rat", name: "Pack Rat", desc: "You start with a random extra piece of van gear in your stash.", apply: function (s) { s.flags.packRat = true; } },
1040 { id: "been_there", name: "Been There", desc: "Whatever's in the snack, you've had worse. Immune to bad trips.", apply: function (s) { s.doseImmune = true; } },
1041 { id: "grease_monkey", name: "Grease Monkey", desc: "You keep the bus alive with spit and wire. Van starts +15.", apply: function (s) { s.van = clamp(s.van + 15, 0, 100); } },
1042 { id: "runs_on_fumes", name: "Runs on Fumes", desc: "You forget to eat but you're always up. -6 Grub, +12 Vibes to start.", apply: function (s) { s.grub = clamp(s.grub - 6, 0, CAP.grub); s.vibes = clamp(s.vibes + 12, 0, CAP.vibes); } },
1043 { id: "lucky", name: "Cosmically Lucky", desc: "The universe provides. Better odds every time you need a miracle.", apply: function (s) { s.flags.lucky = true; } },
1044 { id: "heavy_hauler", name: "Heavy Hauler", desc: "Your rig already holds a milk crate of masters. +2 Tapes, +6 blanks.", apply: function (s) { s.tapes += 2; s.blanks += 6; } },
1045 { id: "deep_roots", name: "Deep Roots", desc: "You've been out here a while and it shows. +8 Lot Cred, +4 Grub.", apply: function (s) { s.cred = clamp(s.cred + 8, 0, 100); s.grub = clamp(s.grub + 4, 0, CAP.grub); } }
1046 ];
1047 var MOD_SLOTS = 3;
1048
1049 // ---------- van parts (installed via the customization screen) ----------
1050 // Each part sits in one slot and contributes to the buff object in recomputeBuffs.
1051 var VAN_PARTS = [
1052 { id: "big_tank", slot: "tank", name: "Aux Fuel Tank", icon: "⛽", desc: "+8 max Gas.", price: 16, buff: function (b) { b.gasCap += 8; } },
1053 { id: "roof_rack", slot: "rack", name: "Roof-Rack Larder", icon: "📦", desc: "+12 max Grub.", price: 12, buff: function (b) { b.grubCap += 12; } },
1054 { id: "hd_shocks", slot: "tires", name: "Heavy-Duty Shocks", icon: "🛞", desc: "Van wears 3 less every leg.", price: 18, buff: function (b) { b.wearReduce += 3; } },
1055 { id: "rebuilt_engine", slot: "engine", name: "Rebuilt Engine", icon: "🔧", desc: "Van wears 4 less; climbs the long grades clean.", price: 26, buff: function (b) { b.wearReduce += 4; } },
1056 { id: "pa_rig", slot: "sound", name: "In-Van PA Rig", icon: "🔊", desc: "Every show gives +4 extra Vibes.", price: 20, buff: function (b) { b.showVibes += 4; } },
1057 { id: "stealth_paint", slot: "paint", name: "Matte \"Nobody Here\" Paint", icon: "🎨", desc: "Security never looks twice. Taping is far safer.", price: 22, buff: function (b) { b.bustGuard += 6; } },
1058 { id: "nakamichi", slot: "deck", name: "Nakamichi Dragon Deck", icon: "🎛", desc: "+10 quality on every tape.", price: 30, buff: function (b) { b.tapeQuality += 10; } },
1059 { id: "disco_ball", slot: "charm", name: "Dashboard Disco Ball", icon: "🪩", desc: "Purely cosmetic. +3 Vibes at every show anyway.", price: 8, buff: function (b) { b.showVibes += 3; } },
1060 { id: "cb_radio", slot: "radio", name: "CB Radio", icon: "📻", desc: "You hear about the smokeys ahead. Fewer nasty road surprises.", price: 14, buff: function (b) { b.roadLuck += 1; } },
1061 // ---- creative-mode decor: style first, with a little something built in ----
1062 { id: "tie_dye_curtains", slot: "curtains", name: "Tie-Dye Curtains", icon: "🌌", desc: "Home is where you park it. +2 Vibes at every show.", price: 6, decor: true, buff: function (b) { b.showVibes += 2; } },
1063 { id: "prayer_flags", slot: "flag", name: "String of Prayer Flags", icon: "🎏", desc: "Good juju flapping in the wind. Fewer nasty road surprises.", price: 5, decor: true, buff: function (b) { b.roadLuck += 1; } },
1064 { id: "bumper_collage", slot: "stickers", name: "Bumper-Sticker Collage", icon: "🏷", desc: "Steal Your Face on the tailgate. You blend into any lot -- stealthier taping.", price: 5, decor: true, buff: function (b) { b.bustGuard += 2; } },
1065 { id: "bear_ornament", slot: "hood", name: "Dancing-Bear Hood Ornament", icon: "🐻", desc: "Purely for style. Fellow heads wave -- +1 Vibes at every show.", price: 4, decor: true, buff: function (b) { b.showVibes += 1; } }
1066 ];
1067
1068 // ---------- consumables (used from the stash) ----------
1069 var CONSUMABLES = [
1070 { id: "spare_tire", name: "Full-Size Spare", icon: "🛞", desc: "Auto-saves you from the next blowout. No button needed.", price: 10, use: null },
1071 { id: "first_aid", name: "Crew First-Aid Kit", icon: "⛑", desc: "Use anytime for +15 Vibes.", price: 9, use: function (s) { s.vibes = clamp(s.vibes + 15, 0, CAP.vibes); return "The kit works wonders on morale. +15 Vibes."; } },
1072 { id: "gas_can", name: "Jerry Can of Gas", icon: "🛢", desc: "Use anywhere for +6 Gas.", price: 7, use: function (s) { s.gas = clamp(s.gas + 6, 0, CAP.gas); return "+6 Gas from the reserve can."; } },
1073 { id: "lucky_dust", name: "A Pinch of Something", icon: "✨", desc: "Use before a show to bless your next recording.", price: 12, use: function (s) { s.flags.blessNext = true; return "You feel ready. Your next tape comes out golden."; } }
1074 ];
1075
1076 // Every ownable item, indexed by id, for name/emoji lookups from the stash.
1077 var GEAR_INDEX = {};
1078 VAN_PARTS.forEach(function (p) { GEAR_INDEX[p.id] = p; });
1079 CONSUMABLES.forEach(function (c) { GEAR_INDEX[c.id] = c; });
1080 function gearById(id) { return GEAR_INDEX[id] || null; }
1081 function gearName(id) { var g = gearById(id); return g ? g.name : id; }
1082 function isVanPart(id) { return VAN_PARTS.some(function (p) { return p.id === id; }); }
1083
1084 // ---------- the Peak: each character's once-per-run moment of clarity ----------
1085 var PEAKS = {
1086 trust: { stat: "cred", to: 90, title: "THE MONEY MEANS NOTHING",
1087 text: "Somewhere in the second set it lands: the Amex, the new van, none of it bought this. You leave your wallet on the dash, unlocked, and walk into the crowd as just another head.",
1088 line: "Ego death by credit card. The lot finally trusts you -- Lot Cred rockets to 90." },
1089 taper: { stat: "vibes", to: 100, title: "PUT THE DECK DOWN",
1090 text: "Levels perfect, reels turning, and for once you close your eyes and just LISTEN. The tape will never hold what this is. You've been documenting a life instead of living it.",
1091 line: "You stop watching the needles and start hearing the music. Vibes fill to 100." },
1092 burnout: { stat: "cred", to: 100, title: "ONE MOMENT OF TERRIBLE CLARITY",
1093 text: "For exactly ninety seconds the fog lifts. You understand the bassline, your own childhood, and the federal tax code simultaneously. Then it's gone -- but the whole lot saw your face during it.",
1094 line: "A rare, total lucidity. Word spreads fast. Lot Cred maxes to 100." },
1095 grandma: { stat: "vibes", to: 100, title: "SHE SEES THE WHOLE TAPESTRY",
1096 text: "Grandma stands very still during 'Morning Dew' and something in her face goes wide and ancient. She has seen every show since '65, and for one held breath she sees them all at once.",
1097 line: "A lifetime of shows folds into a single perfect chord. Vibes fill to 100." },
1098 roadie: { stat: "van", to: 100, title: "YOU AND THE MACHINE ARE ONE",
1099 text: "You lay a hand on the warm hood at 2 a.m. and, without words, the van tells you everything -- the loose belt, the tired plug, the good years left. You just KNOW her now.",
1100 line: "Total mechanical communion. The van is reborn -- purring like the day she rolled off the line." },
1101 professor: { stat: "cred", to: 85, title: "YOU CLOSE THE NOTEBOOK",
1102 text: "Mid-sentence in a footnote about ritual liminality, you stop, look up, and finally just... dance. Badly. Completely. The lot, which had written you off, adopts you on the spot.",
1103 line: "You quit studying the thing and became it. Lot Cred leaps to 85." },
1104 runaway: { stat: "cred", to: 92, title: "YOU'RE HOME",
1105 text: "It hits you between songs, out of nowhere: nobody here is going to leave, and neither are you. For the first time in your whole life you belong somewhere, completely.",
1106 line: "You stop running and start arriving. The family closes around you -- Lot Cred to 92." }
1107 };
1108 var DEFAULT_PEAK = { stat: "vibes", to: 100, title: "THE PEAK",
1109 text: "The whole thing opens in front of you like a second set with no setlist and no clock.",
1110 line: "Everything clarifies at once. Vibes fill to the very top." };
1111 function statCap(stat) {
1112 if (stat === "cred" || stat === "van") return 100;
1113 if (stat === "vibes") return CAP.vibes;
1114 if (stat === "grub") return CAP.grub;
1115 if (stat === "gas") return CAP.gas;
1116 return 999999;
1117 }
1118
1119 // ---------- stash / buff helpers ----------
1120 function hasItem(s, id) { return s.stash && s.stash.indexOf(id) !== -1; }
1121 function takeItem(s, id) { var i = s.stash ? s.stash.indexOf(id) : -1; if (i !== -1) { s.stash.splice(i, 1); return true; } return false; }
1122 function ironGut(s) { return s.doseImmune || (s.flags && s.flags.ironStomach); }
1123 function recomputeBuffs(s) {
1124 var b = { gasCap: 0, grubCap: 0, wearReduce: 0, showVibes: 0, bustGuard: 0, tapeQuality: 0, roadLuck: 0 };
1125 s.vanmods = s.vanmods || {};
1126 Object.keys(s.vanmods).forEach(function (slot) {
1127 var pid = s.vanmods[slot];
1128 var part = pid && VAN_PARTS.filter(function (p) { return p.id === pid; })[0];
1129 if (part) part.buff(b);
1130 });
1131 s.buff = b;
1132 CAP.gas = BASE_CAP.gas + b.gasCap;
1133 CAP.grub = BASE_CAP.grub + b.grubCap;
1134 // Clamp current gas/grub down if a part was removed and lowered the cap.
1135 if (s.gas > CAP.gas) s.gas = CAP.gas;
1136 if (s.grub > CAP.grub) s.grub = CAP.grub;
1137 }
1138
1139 // ---------- state ----------
1140 var S = null;
1141 var HALL = null;
1142
1143 function newGame(role) {
1144 clearSave(); // starting a fresh trip discards any earlier in-progress save
1145 var st = role.stats;
1146 var roster = rollRoster(); // roll each van-mate's gender/name variant for this trip
1147 S = {
1148 role: role.id, roleName: role.name, taper: role.taper, doseImmune: role.doseImmune,
1149 cash: Math.round(st.cash * DNA.cash), gas: st.gas, grub: st.grub, van: st.van, vibes: st.vibes,
1150 tapes: st.tapes, blanks: st.blanks, cred: st.cred,
1151 legendaryTapes: 0, showsAttended: 0, roster: roster,
1152 crewPick: VANMATES.map(function (v) { return { id: v.id, name: roster[v.id], trait: v.trait, on: DEFAULT_CREW.indexOf(v.id) !== -1 }; }),
1153 crew: [], crewNote: "",
1154 stop: 0, day: 1, miles: 0,
1155 pace: "cruisin", rations: "sensible", tapeSet: "second",
1156 hasTicket: false, taped: false,
1157 log: [],
1158 flags: {}, stash: [], vanmods: {}, buff: null,
1159 pickMods: [], tripName: "", peaked: false, packRatGiven: false,
1160 vanName: "", vanStyle: "green", tour: TOUR_YEAR,
1161 missedShow: false, legSlack: 0, collapsed: {}, lastTape: null, collector: null, haggle: null, library: [],
1162 baseStats: { cred: st.cred, van: st.van, grub: st.grub, vibes: st.vibes, tapes: st.tapes, blanks: st.blanks, doseImmune: role.doseImmune },
1163 campaign: { years: campaignYearsFrom(TOUR_YEAR, PENDING_CAMPAIGN_LEN), index: 0 },
1164 view: "role", queue: [], result: null, pendingEnd: null
1165 };
1166 PENDING_CAMPAIGN_LEN = 1; // consumed -- reset for next time
1167 rebuildCrew(S); // materialize S.crew from the default picks
1168 recomputeBuffs(S); // resets CAP to base and seeds S.buff
1169 // starting position: you begin at the tour's first stop, already in.
1170 S.hasTicket = true;
1171 rollMarket(S); // tonight's prices on Shakedown Street
1172 addLog(S, "You fire up the bus at " + STOPS[0].city + ". The tour begins.", "note");
1173 }
1174
1175 // ---------- van-mate selection ----------
1176 // Turn the on/off picker into the live S.crew, preserving each survivor's `ok`
1177 // state (so re-picking mid-build never revives a crew member you've lost) and
1178 // falling back to the archetype's default name when a custom one is blanked out.
1179 function rebuildCrew(s) {
1180 var prev = {};
1181 (s.crew || []).forEach(function (c) { if (c.id) prev[c.id] = c; });
1182 s.crew = s.crewPick.filter(function (p) { return p.on; }).map(function (p) {
1183 var name = (p.name || "").trim() || rosterName(s, p.id);
1184 var was = prev[p.id];
1185 return { id: p.id, name: name, trait: p.trait, ok: was ? was.ok : true };
1186 });
1187 }
1188 function toggleVanmate(id) {
1189 var p = S.crewPick.filter(function (x) { return x.id === id; })[0];
1190 if (!p) return;
1191 var on = S.crewPick.filter(function (x) { return x.on; }).length;
1192 if (p.on && on <= CREW_MIN) { S.crewNote = "You need at least " + CREW_MIN + " van-mate along."; render(); return; }
1193 if (!p.on && on >= CREW_MAX) { S.crewNote = "The van's full -- " + CREW_MAX + " is all it seats. Drop one first."; render(); return; }
1194 p.on = !p.on; S.crewNote = "";
1195 rebuildCrew(S); render();
1196 }
1197 function setCrewName(id, name) {
1198 var p = S.crewPick.filter(function (x) { return x.id === id; })[0];
1199 if (!p) return;
1200 p.name = name;
1201 rebuildCrew(S); // no re-render: called live from the text input, which keeps focus
1202 }
1203 // Randomize who's aboard: a random valid headcount (CREW_MIN..CREW_MAX), then a
1204 // random draw of that many archetypes. Leaves everyone's custom name untouched.
1205 function randomizeCrew() {
1206 var count = rnd(CREW_MIN, CREW_MAX);
1207 var chosen = shuffle(S.crewPick.map(function (p) { return p.id; })).slice(0, count);
1208 S.crewPick.forEach(function (p) { p.on = chosen.indexOf(p.id) !== -1; });
1209 S.crewNote = "";
1210 rebuildCrew(S); render();
1211 }
1212 // Look up an aboard van-mate by archetype, and swap {archetype} tokens in flavor
1213 // text for that crew member's current (possibly renamed) name. If the archetype
1214 // was benched or lost, fall back to whoever's still riding, so the chatter always
1215 // names a real van-mate rather than a ghost.
1216 function crewByArch(s, id) { return (s.crew || []).filter(function (c) { return c.id === id; })[0]; }
1217 // A van-mate's storyline and passive perk only apply while they're aboard AND
1218 // still with you (not benched at the start, not lost along the way).
1219 function crewHas(s, id) { var c = crewByArch(s, id); return !!(c && c.ok); }
1220 // ---------- market / price fluctuation ----------
1221 // Every lot has its own economy. On arrival we roll a stop-wide "mood" (a price
1222 // multiplier) plus a little per-item jitter, so Shakedown Street is never the same
1223 // twice -- a cheap lot one night, a gouging one the next. It's fixed for the visit
1224 // (stable while you shop) and re-rolled each stop. Deb's discount still applies on
1225 // top. Saved with the run so a reload keeps the same prices.
1226 var MOODS = [
1227 { mult: 0.75, label: "Cheap lot tonight -- everything's a steal.", cls: "cyan" },
1228 { mult: 0.9, label: "Prices are running a little soft.", cls: "dim" },
1229 { mult: 1.0, label: "Fair prices up and down the row.", cls: "dim" },
1230 { mult: 1.15, label: "Vendors are asking top dollar tonight.", cls: "amber" },
1231 { mult: 1.35, label: "Gouge city -- this lot knows what it's got.", cls: "danger" }
1232 ];
1233 function rollMarket(s) {
1234 var mood = MOODS[rnd(0, MOODS.length - 1)];
1235 var item = {};
1236 // small per-item wiggle, seeded so it's stable for this stop
1237 LOT_ITEMS.forEach(function (it) { item[it.id] = 0.85 + rnd(0, 30) / 100; }); // 0.85..1.15
1238 s.market = { mult: mood.mult, moodLabel: mood.label, moodCls: mood.cls, item: item };
1239 rollCollector(s);
1240 }
1241 // The tape buyer working the lot tonight. Each has a "want" (a spot/style, or just
1242 // anything from this run) that sets what a tape's worth here -- so selling is about
1243 // reading the lot and matching what you caught, not dumping inventory.
1244 var COLLECTORS = [
1245 { key: "board", want: "is hunting clean SOUNDBOARDS", mult: 1.0, spot: "board", bonus: 1.7, cls: "cyan" },
1246 { key: "crowd", want: "wants raw AUDIENCE tapes -- room tone and all", mult: 1.0, spot: "crowd", bonus: 1.6, cls: "cyan" },
1247 { key: "run", want: "is chasing anything from THIS run", mult: 1.35, spot: null, bonus: 1, cls: "amber" },
1248 { key: "german", want: "is a German tapehead with deutschmarks to burn", mult: 1.4, spot: null, bonus: 1, cls: "amber" },
1249 { key: "flip", want: "is a lowballer just flipping tapes for gas money", mult: 0.7, spot: null, bonus: 1, cls: "dim" },
1250 { key: "fair", want: "pays a fair price, no drama", mult: 1.0, spot: null, bonus: 1, cls: "dim" }
1251 ];
1252 function rollCollector(s) { s.collector = pick(COLLECTORS); }
1253 // What a collector will open with for one of your tapes: a base swayed by Lot Cred
1254 // (your leverage) and your best-known capture, times tonight's demand, with a big
1255 // premium when what you caught matches what they're hunting.
1256 function tapeOffer(s) {
1257 var c = s.collector || { mult: 1, spot: null, bonus: 1 };
1258 var base = rnd(6, 12) + Math.floor(s.cred / 18);
1259 if (s.lastTape) base += Math.floor((s.lastTape.quality || 40) / 20);
1260 var mult = c.mult;
1261 if (c.spot && s.lastTape && s.lastTape.spot === c.spot) mult *= c.bonus;
1262 return Math.max(3, Math.round(base * mult * DNA.hustle)); // TT-DNA lot economy
1263 }
1264 function marketMult(s, id) {
1265 var m = s.market;
1266 if (!m) return 1;
1267 return m.mult * ((m.item && m.item[id]) || 1);
1268 }
1269 // Cosmic Deb's connections knock a dollar off Shakedown vendor prices, applied on
1270 // top of tonight's market mood.
1271 function lotPrice(s, base, id) {
1272 var p = Math.max(1, Math.round(base * marketMult(s, id)));
1273 return crewHas(s, "deb") ? Math.max(1, p - 1) : p;
1274 }
1275 function loseCrewById(s, id) { s.crew.forEach(function (c) { if (c.id === id) c.ok = false; }); }
1276 function resolveCrew(s, str) {
1277 if (!str || String(str).indexOf("{") === -1) return str;
1278 return String(str).replace(/\{(\w+)\}/g, function (m, id) {
1279 var c = crewByArch(s, id);
1280 if (c) return c.name;
1281 var alive = (s.crew || []).filter(function (x) { return x.ok; });
1282 return alive.length ? alive[0].name : "one of your van-mates";
1283 });
1284 }
1285
1286 // ---------- character builder helpers ----------
1287 function modById(id) { return MODS.filter(function (m) { return m.id === id; })[0]; }
1288 // Re-derive the mod-affected stats from the role's baseStats, then re-apply the
1289 // currently selected mods. Idempotent, so toggling a mod on and off is clean.
1290 function applyMods() {
1291 var s = S, base = s.baseStats;
1292 s.cred = base.cred; s.van = base.van; s.grub = base.grub; s.vibes = base.vibes;
1293 s.tapes = base.tapes; s.blanks = base.blanks; s.doseImmune = base.doseImmune;
1294 s.flags = {};
1295 s.pickMods.forEach(function (id) { var m = modById(id); if (m) m.apply(s); });
1296 s.grub = clamp(s.grub, 0, CAP.grub); s.vibes = clamp(s.vibes, 0, CAP.vibes);
1297 }
1298 function toggleMod(id) {
1299 var i = S.pickMods.indexOf(id);
1300 if (i !== -1) { S.pickMods.splice(i, 1); }
1301 else { if (S.pickMods.length >= MOD_SLOTS) { S.modNote = "That's all " + MOD_SLOTS + " mod slots -- drop one first."; render(); return; } S.pickMods.push(id); }
1302 S.modNote = "";
1303 applyMods(); render();
1304 }
1305 // Randomize the mod loadout: a fresh random draw of MOD_SLOTS mods.
1306 function randomizeMods() {
1307 S.pickMods = shuffle(MODS.map(function (m) { return m.id; })).slice(0, MOD_SLOTS);
1308 S.modNote = "";
1309 applyMods(); render();
1310 }
1311 function buyGear(id) {
1312 var g = gearById(id); if (!g) return;
1313 if (isVanPart(id) && (hasItem(S, id) || partInstalled(id))) { S.modNote = "You've already got the " + g.name + " -- one per van."; render(); return; }
1314 if (S.cash < g.price) { S.modNote = "Not enough cash for the " + g.name + "."; render(); return; }
1315 S.cash -= g.price; S.stash.push(id);
1316 Sound.buy();
1317 S.modNote = "Bought the " + g.name + ". It's in your stash.";
1318 render();
1319 }
1320 // Stock up on a stackable item (consumables). Van parts occupy a single slot, so
1321 // they never expose a Max button -- buying nine identical shocks is pointless.
1322 function buyGearMax(id) {
1323 var g = gearById(id); if (!g) return;
1324 if (S.cash < g.price) { S.modNote = "Not enough cash for the " + g.name + "."; render(); return; }
1325 var n = 0;
1326 while (S.cash >= g.price && n < 99) { S.cash -= g.price; S.stash.push(id); n++; }
1327 Sound.buy();
1328 S.modNote = "Bought " + n + "× " + g.name + ". In your stash.";
1329 render();
1330 }
1331 function finalizeBuild() {
1332 applyMods();
1333 if (S.flags.packRat && !S.packRatGiven) {
1334 var owned = {};
1335 Object.keys(S.vanmods).forEach(function (sl) { owned[S.vanmods[sl]] = 1; });
1336 S.stash.forEach(function (id) { owned[id] = 1; });
1337 var avail = VAN_PARTS.filter(function (p) { return !owned[p.id]; });
1338 var part = avail.length ? pick(avail) : pick(VAN_PARTS);
1339 S.stash.push(part.id); S.packRatGiven = true;
1340 addLog(S, "Pack Rat instincts: you already turned up a " + part.name + ".", "good");
1341 }
1342 S.view = "lot"; render();
1343 }
1344
1345 // ---------- van customization ----------
1346 function installPart(id) {
1347 if (!hasItem(S, id) || !isVanPart(id)) return;
1348 var part = VAN_PARTS.filter(function (p) { return p.id === id; })[0];
1349 var slot = part.slot;
1350 if (S.vanmods[slot]) S.stash.push(S.vanmods[slot]); // pop the old one back to the stash
1351 takeItem(S, id);
1352 S.vanmods[slot] = id;
1353 recomputeBuffs(S); render();
1354 }
1355 function uninstallSlot(slot) {
1356 if (!S.vanmods[slot]) return;
1357 S.stash.push(S.vanmods[slot]);
1358 S.vanmods[slot] = null;
1359 recomputeBuffs(S); render();
1360 }
1361 function useConsumable(id) {
1362 var c = gearById(id);
1363 if (!c || !hasItem(S, id) || typeof c.use !== "function") return;
1364 var line = c.use(S);
1365 takeItem(S, id);
1366 addLog(S, line, "good");
1367 Sound.good();
1368 if (checkDeath()) return;
1369 showResult(c.name.toUpperCase(), [line], "stash");
1370 }
1371
1372 function crewAlive(s) { return s.crew.filter(function (c) { return c.ok; }).length; }
1373 function loseCrew(s, name) { s.crew.forEach(function (c) { if (c.name === name) c.ok = false; }); }
1374
1375 // ---------- crew resource-pooling ----------
1376 // When the vibes are good, the van-mates stop treating their stashes as private
1377 // and throw in with you -- gas money, snacks, whatever you're shortest on. The
1378 // better the vibes, the likelier (and the whole thing shuts off when morale is
1379 // low, because that's exactly when people start hoarding). Runs once on arrival
1380 // and stashes a line on S.crewPool for the arrival card to surface.
1381 var POOL_VIBES = 65;
1382 function maybePoolCrew(s) {
1383 s.crewPool = null;
1384 var alive = s.crew.filter(function (c) { return c.ok; });
1385 if (!alive.length || s.vibes < POOL_VIBES) return;
1386 var p = clamp(0.2 + (s.vibes - POOL_VIBES) / 140, 0.2, 0.62);
1387 if (!chance(p)) return;
1388 var who = alive[Math.floor(rand() * alive.length)];
1389 var critter = /morale/.test(who.trait || "");
1390 var need = legNeeds(s);
1391 var opt;
1392 if (critter) opt = "vibes";
1393 else if (need && s.gas < need.gas + 1) opt = "gas";
1394 else if (need && s.grub < need.grub + 1) opt = "grub";
1395 else if (s.cash <= 8) opt = "cash";
1396 else opt = pick(["cash", "grub", "vibes"]);
1397 var line;
1398 if (opt === "gas") { var g = rnd(2, 4); s.gas = clamp(s.gas + g, 0, CAP.gas); line = who.name + " chips in from the coffee-can gas fund. +" + g + " Gas."; }
1399 else if (opt === "grub") { var gr = rnd(3, 6); s.grub = clamp(s.grub + gr, 0, CAP.grub); line = who.name + " digs a secret snack stash out of a backpack and shares it around. +" + gr + " Grub."; }
1400 else if (opt === "cash") { var c = rnd(4, 12); s.cash += c; line = who.name + " quietly folds their lot money in with yours. \"We're all in this,\" they shrug. +" + money(c) + "."; }
1401 else { var v = rnd(3, 6); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); line = who.name + " keeps the good energy rolling the whole way into town. +" + v + " Vibes."; }
1402 s.flags.crewPooled = true;
1403 s.crewPool = { who: who.name, line: line };
1404 addLog(s, line, "good");
1405 }
1406
1407 // Wharf Rat Randy's perk: he wanders off at the stop and turns up with a small
1408 // free haul. Runs once on arrival; surfaced on the arrival card via S.crewFind.
1409 function maybeRandyFind(s) {
1410 s.crewFind = null;
1411 if (!crewHas(s, "randy") || !chance(0.32)) return;
1412 var who = crewByArch(s, "randy").name;
1413 var line;
1414 if (chance(0.5)) { var b = rnd(2, 3); s.blanks += b; line = who + " wandered off at the rest stop and came back with " + b + " blank tapes. Don't ask where from."; }
1415 else { var g = rnd(3, 5); s.grub = clamp(s.grub + g, 0, CAP.grub); line = who + " reappears with an armful of somebody's abandoned snacks. +" + g + " Grub."; }
1416 s.crewFind = { line: line };
1417 addLog(s, line, "good");
1418 }
1419
1420 // When morale bottoms out, a van-mate may ask to be let off. Only fires below a
1421 // low Vibes floor and never empties the van (checkDeath handles the last one), so
1422 // it's the warning shot before the dosing gameover -- keep Vibes up or lose people.
1423 function maybeCrewQuits(s) {
1424 s.crewQuit = null;
1425 if (s.vibes >= 18) return;
1426 var alive = s.crew.filter(function (c) { return c.ok; });
1427 if (alive.length <= 1) return;
1428 var p = clamp((0.5 - s.vibes / 40) / DNA.loyal, 0.05, 0.75); // lower Vibes -> likelier to bail; TT-DNA crew loyalty
1429 if (!chance(p)) return;
1430 var who = pick(alive);
1431 loseCrew(s, who.name);
1432 s.crewQuit = { line: who.name + " has had enough of the low road and asks to be let off in town. You lose a van-mate." };
1433 addLog(s, s.crewQuit.line, "bad");
1434 }
1435
1436 function addLog(s, text, cls) { s.log.unshift({ t: text, c: cls || "" }); if (s.log.length > 30) s.log.pop(); }
1437
1438 // ---------- labels ----------
1439 function vibesLabel(v) { return v >= 80 ? "Blissed" : v >= 60 ? "Heady" : v >= 40 ? "Coming Down" : v >= 20 ? "Fried" : "Crispy"; }
1440 function vanLabel(v) { return v >= 85 ? "Purring" : v >= 65 ? "Running" : v >= 45 ? "Coughin'" : v >= 25 ? "Wheezing" : "On its last legs"; }
1441
1442 // ---------- consumption / driving ----------
1443 // miles = how far the van covers in a day at this pace. That now decides whether
1444 // you reach the next town before showtime: a long haul on a short date gap forces
1445 // you to floor it (more wear, worse Vibes) or miss the show. Faster = fewer days.
1446 var PACE = {
1447 scenic: { miles: 190, wear: 0, vibes: 3, label: "Scenic Route" },
1448 mellow: { miles: 260, wear: 0, vibes: 1, label: "Mellow" },
1449 cruisin: { miles: 380, wear: 4, vibes: 0, label: "Cruisin'" },
1450 balls: { miles: 520, wear: 12, vibes: -4, label: "Full Tilt" }
1451 };
1452 // Real days between two shows, from their Internet Archive dates.
1453 function daysBetween(ymdA, ymdB) {
1454 var a = Date.parse(ymdA + "T00:00:00Z"), b = Date.parse(ymdB + "T00:00:00Z");
1455 if (isNaN(a) || isNaN(b)) return 3;
1456 return Math.max(1, Math.round((b - a) / 86400000));
1457 }
1458 // Days this leg takes at the chosen pace (Lead Foot covers extra ground).
1459 function legDays(s, miles) {
1460 var lead = s.flags && s.flags.leadFoot;
1461 return Math.max(1, Math.ceil(miles / (PACE[s.pace].miles * (lead ? 1.25 : 1))));
1462 }
1463 // Rations now scale with the size of the van. `per` is grub eaten per head each
1464 // leg; `vibes` is the morale swing. Generous double-feeds everyone (grub-heavy,
1465 // happy); sparse starves them (grub-cheap, and the morale hit grows with every
1466 // extra mouth -- see driveOn). So both rations AND headcount hit hard now.
1467 var RATIONS = {
1468 feast: { per: 3, vibes: 5, label: "Feast" },
1469 generous: { per: 2, vibes: 3, label: "Generous" },
1470 sensible: { per: 1, vibes: 0, label: "Sensible" },
1471 sparse: { per: 0.5, vibes: -2, label: "Bark & berries" }
1472 };
1473 // Extra hands = automatic help. A fuller van parties harder at the show and hustles
1474 // better on the lot -- but eats more on the road (legNeeds grub). One free head
1475 // (you + a buddy) carries no bonus; each additional van-mate stacks.
1476 function crewBonus(s) { return Math.max(0, crewAlive(s) - 1); }
1477
1478 function legNeeds(s) {
1479 var next = STOPS[s.stop + 1];
1480 if (!next) return null;
1481 var gas = Math.ceil(next.miles / 70 * DNA.gas); // TT-DNA gas guzzling
1482 var rat = RATIONS[s.rations];
1483 var grub = Math.max(2, Math.ceil(crewAlive(s) * rat.per) + Math.floor(next.miles / 300) - (crewHas(s, "doses") ? 1 : 0));
1484 var gap = daysBetween(STOPS[s.stop].ymd, next.ymd); // days until the next show
1485 return { gas: gas, grub: grub, miles: next.miles, next: next, gap: gap };
1486 }
1487
1488 // Rather than just refusing to leave when the tank or the larder is short,
1489 // swing by the pump / a food vendor on the way out and cover the gap with real
1490 // cash at tonight's prices -- as far as the wallet actually stretches. Whatever's
1491 // still short after this falls through to the old "can't afford it" card.
1492 function autoRestockForTravel(need) {
1493 var gasItem = LOT_ITEMS.filter(function (it) { return it.id === "gas"; })[0];
1494 var gasPrice = lotPrice(S, gasItem.price, gasItem.id);
1495 var gasBought = 0, gasSpent = 0;
1496 while (S.gas < need.gas && S.cash >= gasPrice) { S.cash -= gasPrice; gasItem.apply(S); gasBought++; gasSpent += gasPrice; }
1497 if (gasBought) addLog(S, "Swung by the pump for " + gasBought + " round" + (gasBought > 1 ? "s" : "") + " of gas on the way out, -" + money(gasSpent) + ".", "note");
1498
1499 var foodItem = LOT_ITEMS.filter(function (it) { return it.id === "tamales"; })[0];
1500 var foodPrice = lotPrice(S, foodItem.price, foodItem.id);
1501 var foodBought = 0, foodSpent = 0;
1502 while (S.grub < need.grub && S.cash >= foodPrice) { S.cash -= foodPrice; foodItem.apply(S); foodBought++; foodSpent += foodPrice; }
1503 if (foodBought) addLog(S, "Grabbed " + foodBought + " round" + (foodBought > 1 ? "s" : "") + " of tamales for the road, -" + money(foodSpent) + ".", "note");
1504 }
1505
1506 function driveOn() {
1507 var need = legNeeds(S);
1508 if (!need) return;
1509 autoRestockForTravel(need);
1510 if (S.gas < need.gas) {
1511 showResult("NOT ENOUGH GAS", [
1512 "The next haul to " + need.next.city + " is " + need.miles + " miles.",
1513 "You need " + need.gas + " gas and only have " + S.gas + ", and you're too broke to cover the rest at the pump.",
1514 "Work the lot for cash first, then try again."
1515 ], "lot");
1516 return;
1517 }
1518 // consume
1519 S.gas = clamp(S.gas - need.gas, 0, CAP.gas);
1520 var pace = PACE[S.pace], rat = RATIONS[S.rations];
1521 var grubEat = need.grub;
1522 if (S.grub >= grubEat) { S.grub -= grubEat; }
1523 else { var deficit = grubEat - S.grub; S.grub = 0; S.vibes = clamp(S.vibes - deficit * 3, 0, CAP.vibes); addLog(S, "The larder ran dry on the road. The crew grumbles.", "bad"); }
1524 var leadFoot = S.flags && S.flags.leadFoot;
1525 var wear = 4 + pace.wear + (need.miles > 600 ? 4 : 0) + rnd(0, 2) + (leadFoot ? 3 : 0) - S.buff.wearReduce - (crewHas(S, "hitch") ? 1 : 0);
1526 S.van = clamp(S.van - Math.max(0, Math.round(wear * DNA.wear)), 0, 100); // TT-DNA van wear
1527 // Road fatigue: every leg grinds the crew down, and the long hauls grind harder.
1528 // A skimpy larder on a full van craters morale -- sparse rations cost an extra
1529 // point of Vibes per head. Rations, pace, and a disco ball / PA rig push back,
1530 // so Vibes is a resource you actively fight to hold, not a number that climbs.
1531 var fatigue = 4 + Math.floor(need.miles / 200);
1532 var rationVibes = rat.vibes - (S.rations === "sparse" ? crewAlive(S) : 0);
1533 S.vibes = clamp(S.vibes + rationVibes + pace.vibes + rnd(-2, 2) - fatigue, 0, CAP.vibes);
1534 S.miles += need.miles;
1535 // Scheduling: the leg takes legDays at this pace; the next show is need.gap days
1536 // out. Beat it and you arrive with spare days (sightseeing/rest); take too long
1537 // and you roll in after the last note -- you MISS the show. The finale is never
1538 // missed (you always floor it for the last night).
1539 var travelDays = legDays(S, need.miles);
1540 S.day += travelDays;
1541 var missed = travelDays > need.gap;
1542 // advance
1543 var fromCity = STOPS[S.stop].city;
1544 S.stop += 1;
1545 S.hasTicket = false; S.taped = false;
1546 if (STOPS[S.stop].finale) missed = false;
1547 S.missedShow = missed;
1548 S.legSlack = missed ? 0 : Math.max(0, need.gap - travelDays);
1549 if (missed) {
1550 S.vibes = clamp(S.vibes - 8, 0, CAP.vibes); S.cred = clamp(S.cred - 4, 0, 100);
1551 addLog(S, "You rolled into " + STOPS[S.stop].city + " too late -- the show was over. -8 Vibes, -4 Lot Cred.", "bad");
1552 }
1553 addLog(S, "You roll " + need.miles + " miles to " + STOPS[S.stop].city + " in " + travelDays + " day" + (travelDays > 1 ? "s" : "") + ".", "note");
1554 // build event queue for this leg (a CB radio thins out the nasty surprises)
1555 var roadLuck = S.buff.roadLuck + (crewHas(S, "hitch") ? 1 : 0);
1556 var n = 1 + (need.miles > 400 ? 1 : 0) + (chance(0.3 - 0.15 * roadLuck) ? 1 : 0);
1557 n = Math.min(3, Math.round(Math.min(n, 3) * DNA.chaos)); // TT-DNA road chaos
1558 S.queue = [];
1559 // Archetype-locked events (a van-mate's own storyline) only surface when that
1560 // van-mate is actually aboard -- see each event's `needs`.
1561 var pool = EVENTS.filter(function (e) { return !e.needs || crewHas(S, e.needs); });
1562 for (var i = 0; i < n && pool.length; i++) { var e = pick(pool); pool = pool.filter(function (x) { return x.id !== e.id; }); S.queue.push(e.id); }
1563 // The Peak: a once-per-run moment of clarity. Eligible a couple of stops in,
1564 // random after that, and guaranteed on the final leg if it hasn't hit yet.
1565 if (!S.peaked) {
1566 var lastLeg = !STOPS[S.stop + 1];
1567 if (S.stop >= 2 && (chance(0.45) || lastLeg)) S.peakQueued = true;
1568 }
1569 S.mode = "leg";
1570 // play the travel intermission, then resume the leg (events + arrival)
1571 S.drive = { fromCity: fromCity, toCity: STOPS[S.stop].city, miles: need.miles,
1572 ms: reduceMotion() ? 500 : 3000, scenery: buildScenery(),
1573 caps: shuffle(DRIVE_CAPTIONS).slice(0, 5).map(function (c) { return resolveCrew(S, c); }) };
1574 S.view = "driving"; render();
1575 }
1576
1577 var driveTimers = [];
1578 function clearDriveTimers() { driveTimers.forEach(function (t) { clearInterval(t); clearTimeout(t); }); driveTimers = []; Sound.driveStop(); }
1579 function startDriveAnim() {
1580 clearDriveTimers();
1581 var d = S.drive; if (!d) return;
1582 Sound.driveStart();
1583 var t0 = (window.performance && performance.now) ? performance.now() : Date.now();
1584 var capIdx = 0;
1585 driveTimers.push(setInterval(function () {
1586 var now = (window.performance && performance.now) ? performance.now() : Date.now();
1587 var prog = clamp((now - t0) / d.ms, 0, 1);
1588 var odo = document.getElementById("drive-odo");
1589 if (odo) odo.textContent = Math.round(prog * d.miles) + " / " + d.miles + " mi";
1590 }, 60));
1591 driveTimers.push(setInterval(function () {
1592 capIdx = (capIdx + 1) % d.caps.length;
1593 var cap = document.getElementById("drive-cap");
1594 if (cap) cap.textContent = d.caps[capIdx];
1595 }, 850));
1596 driveTimers.push(setTimeout(finishDrive, d.ms + 120));
1597 }
1598 function finishDrive() {
1599 clearDriveTimers();
1600 S.drive = null;
1601 resumeLeg();
1602 }
1603
1604 function resumeLeg() {
1605 // check death first
1606 if (checkDeath()) return;
1607 // the Peak interjects once, before this leg's events resolve
1608 if (S.peakQueued) { S.peakQueued = false; showPeak(); return; }
1609 if (S.queue && S.queue.length) {
1610 var id = S.queue.shift();
1611 var ev = EVENTS.filter(function (e) { return e.id === id; })[0];
1612 S.currentEvent = ev; S.view = "event"; render(); return;
1613 }
1614 // van check on arrival
1615 if (S.van <= 0) {
1616 if (S.cash >= 50) { S.cash -= 50; S.van = 30; addLog(S, "The bus died on the shoulder. A tow and a prayer cost you $50.", "bad"); }
1617 else { endGame(false, "The bus finally gave out for good, and you couldn't cover the tow."); return; }
1618 }
1619 if (checkDeath()) return;
1620 // Vibes death-spiral: when morale craters, people start to walk. A van-mate may
1621 // quit at the next town -- so letting Vibes bottom out doesn't just risk the
1622 // dosing gameover, it thins the crew (and with it their perks and hands).
1623 maybeCrewQuits(S);
1624 if (checkDeath()) return;
1625 // Good vibes -> the crew pools resources with you before you hit the lot.
1626 maybePoolCrew(S);
1627 maybeRandyFind(S);
1628 rollMarket(S); // fresh prices for this town's Shakedown Street
1629 S.mode = null;
1630 S.view = "arrive"; render();
1631 }
1632
1633 function checkDeath() {
1634 if (S.vibes <= 0) { endGame(false, "dosed"); return true; }
1635 if (crewAlive(S) <= 0) { endGame(false, "alone"); return true; }
1636 return false;
1637 }
1638
1639 // The Peak: apply this character's insight (drastically moving one stat) and show it.
1640 function showPeak() {
1641 var p = PEAKS[S.role] || DEFAULT_PEAK;
1642 var target = p.to + (S.flags.nightOwl ? 4 : 0);
1643 S[p.stat] = clamp(target, 0, statCap(p.stat));
1644 S.peaked = true;
1645 S.peakData = p;
1646 addLog(S, "You PEAKED -- " + p.title.toLowerCase() + ".", "good");
1647 Sound.peak();
1648 S.view = "peak"; render();
1649 }
1650
1651 // ---------- lot actions ----------
1652 function doHustle(id) {
1653 var h = HUSTLES.filter(function (x) { return x.id === id; })[0];
1654 if (!h.need(S)) { showResult("CAN'T RIGHT NOW", ["You don't have what that hustle needs."], "lot"); return; }
1655 var cash0 = S.cash;
1656 var r = h.run(S);
1657 if (S.cash > cash0 && DNA.hustle !== 1) S.cash = cash0 + Math.max(1, Math.round((S.cash - cash0) * DNA.hustle)); // TT-DNA lot economy
1658 // Extra hands work the lot with you -- a fuller van makes more money.
1659 var lines = [r.line];
1660 var cb = crewBonus(S);
1661 if (r.good && cb > 0) { var extra = rnd(1, cb + 1); S.cash += extra; lines.push("The crew pitches in -- +" + money(extra) + " more between them."); }
1662 addLog(S, r.line, r.good ? "good" : "bad");
1663 Sound[r.good ? "good" : "bad"]();
1664 // Working the lot always costs a little morale; when you're already fried it
1665 // costs more, so a Vibes hole is something you have to climb out of at a show.
1666 S.vibes = clamp(S.vibes - (S.vibes < 20 ? 3 : 1), 0, CAP.vibes);
1667 if (checkDeath()) return;
1668 showResult("WORKING THE LOT", lines, "lot");
1669 }
1670
1671 function buyItem(id) {
1672 var it = LOT_ITEMS.filter(function (x) { return x.id === id; })[0];
1673 var price = lotPrice(S, it.price, it.id);
1674 if (S.cash < price) { showResult("NOT ENOUGH CASH", ["That's " + money(price) + " and you've got " + money(S.cash) + "."], "shakedown"); return; }
1675 S.cash -= price;
1676 Sound.buy();
1677 var line = it.apply(S);
1678 addLog(S, it.who + ": " + line, "note");
1679 if (checkDeath()) return;
1680 showResult(it.name.toUpperCase(), [line], "shakedown");
1681 }
1682
1683 function buyItemMax(id) {
1684 var it = LOT_ITEMS.filter(function (x) { return x.id === id; })[0];
1685 var price = lotPrice(S, it.price, it.id);
1686 if (S.cash < price) { showResult("NOT ENOUGH CASH", ["That's " + money(price) + " and you've got " + money(S.cash) + "."], "shakedown"); return; }
1687 var r = buyMax(price, function (s) { return it.apply(s); });
1688 if (!r.n) { showResult("ALREADY STOCKED", ["No point buying more of that right now."], "shakedown"); return; }
1689 Sound.buy();
1690 addLog(S, it.who + ": bought " + r.n + "× " + it.name + " for " + money(r.n * price) + ".", "note");
1691 if (checkDeath()) return;
1692 showResult("STOCKED UP: " + it.name.toUpperCase(), ["Bought " + r.n + "× " + it.name + " for " + money(r.n * price) + ".", r.line], "shakedown");
1693 }
1694
1695 // ---------- selling tapes: the blanket haggle ----------
1696 // Open a negotiation with tonight's collector. `copy` = you're dubbing a fresh copy
1697 // (a blank is spent only if the deal closes; your master stays in the case).
1698 function startSellTape(copy) {
1699 if (S.tapes < 1) { showResult("NOTHING TO SELL", ["Your tape case is empty, taper. Go catch a show."], "shakedown"); return; }
1700 if (copy && S.blanks < 1) { showResult("NO BLANKS", ["You need a blank tape to dub a copy. Grab a 3-pack on Shakedown Street."], "shakedown"); return; }
1701 if (!S.collector) rollCollector(S);
1702 S.haggle = { copy: !!copy, offer: tapeOffer(S), counters: 0 };
1703 S.view = "haggle"; render();
1704 }
1705 function haggleClose(h) {
1706 var got = h.offer;
1707 S.cash += got;
1708 if (h.copy) { S.blanks -= 1; }
1709 else { S.tapes -= 1; }
1710 Sound.buy();
1711 var what = h.copy ? "a dubbed copy" : "a tape off the blanket";
1712 var lost = tapersCode(S, 1); // TT-DNA
1713 addLog(S, "Sold " + what + " to a collector. +" + money(got) + "." + lost, lost ? "bad" : "good");
1714 S.haggle = null;
1715 showResult("SOLD " + (h.copy ? "A COPY" : "A TAPE"), ["The collector counts it out. +" + money(got) + " in the case." + (h.copy ? " Your master stays in the bag." : "") + lost], "shakedown");
1716 }
1717 function haggleCounter() {
1718 var h = S.haggle; if (!h) return;
1719 h.counters += 1;
1720 // Lot Cred is leverage; Deb greases it. Push too hard and they walk.
1721 var p = clamp(0.55 + S.cred / 220 + (crewHas(S, "deb") ? 0.12 : 0) - h.counters * 0.12, 0.15, 0.85);
1722 if (chance(p)) {
1723 h.offer = Math.round(h.offer * (1 + rnd(15, 40) / 100));
1724 Sound.good();
1725 render(); // re-open the haggle with the better number
1726 } else {
1727 // they walk -- no sale, no blank spent, a small ding for the awkwardness
1728 S.vibes = clamp(S.vibes - 2, 0, CAP.vibes);
1729 S.cred = clamp(S.cred - 1, 0, 100);
1730 addLog(S, "You pushed too hard -- the collector packs up and walks. -2 Vibes.", "bad");
1731 S.haggle = null;
1732 if (checkDeath()) return;
1733 showResult("THEY WALKED", ["\"Have a nice show,\" they say, and they mean the opposite.", "No sale. -2 Vibes, -1 Lot Cred. Read the lot better next time."], "shakedown");
1734 }
1735 }
1736
1737 function tryMiracle() {
1738 if (S.hasTicket) { showResult("YOU'RE ALREADY IN", ["You've got a way into tonight's show. Go tape it!"], "lot"); return; }
1739 var ch = clamp(0.32 + S.cred / 320 + S.vibes / 500 + (S.flags && S.flags.lucky ? 0.15 : 0), 0.1, 0.92);
1740 if (chance(ch)) { S.hasTicket = true; Sound.miracle(); addLog(S, "Scored a miracle! You're in tonight.", "good"); showResult("I NEED A MIRACLE", ["You hold up one finger. A stranger smiles, peels off a spare, and hands it over.", "You're in tonight!"], "lot"); }
1741 else { S.vibes = clamp(S.vibes - 4, 0, CAP.vibes); Sound.bad(); addLog(S, "No miracle today. Spent the afternoon fingering the sky.", "bad"); if (checkDeath()) return; showResult("I NEED A MIRACLE", ["You hold up one finger for hours. The universe was busy.", "-4 Vibes. There's always the crowd outside."], "lot"); }
1742 }
1743
1744 // taping the show -- an interactive skill mini-game
1745 // WHEN you roll tape: first set is forgiving but the band's still warming up;
1746 // the encore is the magic hour -- best quality if you nail it, but tighter windows
1747 // and a faster needle. A real risk/reward knob on top of WHERE you record.
1748 var TAPE_SETS = {
1749 first: { qMult: 0.88, half: 3, speed: 0.9, label: "First set" },
1750 second: { qMult: 1.0, half: 0, speed: 1.0, label: "Second set" },
1751 encore: { qMult: 1.22, half: -3, speed: 1.25, label: "Hold for the encore" }
1752 };
1753 // WHERE (which room) matters too: tight theaters and halls capture crisp; big
1754 // arenas are neutral; stadiums, bowls and fields are boomy and wind-blown.
1755 function roomBonus(stop) {
1756 var v = stop.venue || "";
1757 if (/Theat|Hall|Auditorium|Palladium|Opera|Ballroom|Palace|Beacon|Uptown|Paramount/i.test(v)) return 6;
1758 if (/Stadium|Bowl|Field|Racecourse|Raceway|Park|Meadows|Fronton|Grandstand|Coliseum|Kezar/i.test(v)) return -6;
1759 return 0;
1760 }
1761 function startTaping(spot) {
1762 if (S.blanks < 1) { showResult("NO BLANKS", ["You're out of blank tapes. Grab a 3-pack on Shakedown Street."], "lot"); return; }
1763 var set = TAPE_SETS[S.tapeSet] ? S.tapeSet : "second";
1764 var sc = TAPE_SETS[set];
1765 var cfg;
1766 if (spot === "crowd") {
1767 cfg = { speed: 0.9, stages: [
1768 { label: "Set your levels", center: 50, half: 22 },
1769 { label: "Aim the mic at the PA stacks", center: 50, half: 20 } ] };
1770 } else if (spot === "section") {
1771 cfg = { speed: 1.15, stages: [
1772 { label: "Set your levels", center: 54, half: 16 },
1773 { label: "Find the stereo image", center: 46, half: 15 },
1774 { label: "Dodge your chatty neighbor", center: 50, half: 15, danger: true } ] };
1775 } else { // board
1776 cfg = { speed: 1.55, stages: [
1777 { label: "Splice into the board", center: 50, half: 12 },
1778 { label: "Ride the gain (no clipping!)", center: 60, half: 10 },
1779 { label: "Time it past the tour cop", center: 50, half: 12, danger: true } ] };
1780 }
1781 // Stealth paint (bustGuard) widens the green window on the "security" stages; the
1782 // chosen set narrows or widens every window and speeds up / slows the needle.
1783 var guard = S.buff.bustGuard || 0;
1784 var stages = cfg.stages.map(function (st) {
1785 var half = Math.max(6, st.half + sc.half + (st.danger ? guard : 0));
1786 return { label: st.label, center: st.center, half: half, danger: !!st.danger };
1787 });
1788 var speed = cfg.speed * sc.speed;
1789 S.mini = { spot: spot, set: set, stages: stages, speed: speed, baseSpeed: speed, idx: 0, results: [],
1790 pos: rnd(5, 95), dir: (chance(0.5) ? 1 : -1), busted: false, raf: 0,
1791 combo: 0, bestCombo: 0, lastRating: null };
1792 S.view = "mini"; render();
1793 }
1794
1795 function startMiniLoop() {
1796 var m = S.mini; if (!m) return;
1797 if (m.raf) cancelAnimationFrame(m.raf);
1798 function step() {
1799 if (S.view !== "mini" || !S.mini) return;
1800 m.pos += m.dir * m.speed;
1801 if (m.pos >= 100) { m.pos = 100; m.dir = -1; }
1802 if (m.pos <= 0) { m.pos = 0; m.dir = 1; }
1803 var n = document.getElementById("needle");
1804 if (!n) return;
1805 n.style.left = m.pos + "%";
1806 m.raf = requestAnimationFrame(step);
1807 }
1808 m.raf = requestAnimationFrame(step);
1809 }
1810
1811 function lockMini() {
1812 var m = S.mini; if (!m) return;
1813 if (m.raf) cancelAnimationFrame(m.raf);
1814 var st = m.stages[m.idx];
1815 var dist = Math.abs(m.pos - st.center);
1816 var inZone = dist <= st.half;
1817 var ratio = dist / st.half; // 0 = dead-center, 1 = edge of the sweet spot
1818 // Grade each lock: nailing the center is a PERFECT, and stringing perfects/greats
1819 // together builds a combo that pays off in the final tape quality.
1820 var rating = !inZone ? "miss" : (ratio <= 0.22 ? "perfect" : (ratio <= 0.6 ? "great" : "good"));
1821 var acc = inZone ? Math.round(100 - ratio * 25)
1822 : Math.max(0, Math.round(55 - (dist - st.half) * 1.3));
1823 if (rating === "perfect" || rating === "great") m.combo += 1;
1824 else if (rating === "miss") m.combo = 0;
1825 if (m.combo > m.bestCombo) m.bestCombo = m.combo;
1826 m.lastRating = rating;
1827 m.results.push({ inZone: inZone, acc: acc, danger: !!st.danger, rating: rating, combo: m.combo });
1828 Sound.lock(acc, inZone);
1829 if (st.danger && !inZone) m.busted = true;
1830 m.idx++;
1831 // The needle picks up speed as the recording goes on -- later stages bite harder.
1832 m.speed = Math.min(m.speed * 1.12, m.baseSpeed * 2.2 + 0.6);
1833 if (m.busted || m.idx >= m.stages.length) { finishMini(); return; }
1834 m.pos = rnd(5, 95); m.dir = chance(0.5) ? 1 : -1;
1835 render();
1836 }
1837
1838 function finishMini() {
1839 var m = S.mini;
1840 var accs = m.results.map(function (r) { return r.acc; });
1841 var avg = accs.reduce(function (a, b) { return a + b; }, 0) / accs.length;
1842 var q;
1843 if (m.spot === "crowd") q = avg * 0.55;
1844 else if (m.spot === "section") q = avg * 0.82 + S.taper / 3 + S.cred / 8;
1845 else q = avg * 0.95 + 5 + S.taper / 2 + S.cred / 6;
1846 // Gear and perks: better deck, golden ears, the disputed sweet spot, a blessing.
1847 q += (S.buff.tapeQuality || 0) + (S.flags.goldenEars ? 8 : 0) + (S.flags.goodSpot ? 6 : 0) + (crewHas(S, "rita") ? 6 : 0);
1848 if (S.flags.goodSpot) S.flags.goodSpot = false;
1849 // Combo payoff: each link past the first adds quality and gets called out.
1850 var comboBonus = m.bestCombo >= 2 ? (m.bestCombo - 1) * 5 : 0;
1851 q += comboBonus;
1852 var extra = comboBonus ? [m.bestCombo + "-lock combo -- steady hands paid off. +" + comboBonus + " tape quality."] : [];
1853 // WHEN (the set) scales the whole thing; WHERE (the room) and how fried you are
1854 // add or subtract on top -- so timing, venue, and Vibes all move the tape now.
1855 var sc = TAPE_SETS[m.set || "second"];
1856 var rb = roomBonus(STOPS[S.stop]);
1857 var vibesFactor = clamp((S.vibes - 55) / 9, -6, 6);
1858 q = q * sc.qMult + rb + vibesFactor;
1859 if (m.set === "encore") extra.push("You held out for the encore -- if it lands, it's the one people chase.");
1860 if (rb > 0) extra.push("Tight, dry room -- crisp on tape.");
1861 else if (rb < 0) extra.push("Big boomy room -- some mud on the low end.");
1862 if (S.vibes < 25) extra.push("You're fried, and your hands know it -- the levels wander.");
1863 if (S.flags.blessNext) { q = Math.max(q, 92); S.flags.blessNext = false; }
1864 applyTapeOutcome(m.spot, Math.round(q), m.busted, extra);
1865 }
1866
1867 function applyTapeOutcome(spot, quality, busted, extra) {
1868 var stop = STOPS[S.stop];
1869 S.blanks -= 1;
1870 var lines = [], title = "TAPING: " + stop.venue.toUpperCase();
1871 if (busted) {
1872 var fine = rnd(0, 20);
1873 S.cash = Math.max(0, S.cash - fine);
1874 S.cred = clamp(S.cred - 5, 0, 100);
1875 S.vibes = clamp(S.vibes - 8, 0, CAP.vibes);
1876 lines.push("Security clocks your rig and walks you out." + (fine ? " A guard \"confiscates\" " + money(fine) + "." : ""));
1877 lines.push("-1 blank, -8 Vibes, -5 Lot Cred. Rough.");
1878 addLog(S, "Busted taping at " + stop.venue + ".", "bad");
1879 Sound.bust();
1880 } else {
1881 var q = quality >= 85 ? "a MASTERPIECE" : quality >= 60 ? "a solid, trade-worthy capture" : quality >= 40 ? "listenable, if a little muddy" : "hissy and distant, but it's yours";
1882 S.tapes += 1;
1883 // Remember this capture so the lot's tape buyer can pay for what you actually got.
1884 S.lastTape = { spot: spot, quality: quality, venue: stop.venue };
1885 var legendaryCatch = stop.legendary && (spot === "board" || spot === "section") && quality >= 60;
1886 if (legendaryCatch) {
1887 S.legendaryTapes += 1; S.cred = clamp(S.cred + 12, 0, 100);
1888 var md = stop.ymd ? (+stop.ymd.slice(5, 7)) + "/" + (+stop.ymd.slice(8, 10)) : stop.date;
1889 lines.push("You catch " + stop.city.split(",")[0] + " " + md + " clean off the board. This is THE tape. The one people will chase for decades.");
1890 lines.push("+1 LEGENDARY tape, +12 Lot Cred.");
1891 addLog(S, "Captured the legendary " + stop.city + " show!", "good");
1892 Sound.legendary();
1893 } else {
1894 lines.push("You get " + q + ".");
1895 lines.push("+1 tape in the case.");
1896 addLog(S, "Taped " + stop.venue + " (" + q + ").", "good");
1897 Sound.good();
1898 }
1899 // The library is a permanent log of every show you've taped this run -- unlike
1900 // the sellable tape case (S.tapes), entries stay even after a tape is sold.
1901 S.library.push({ venue: stop.venue, city: stop.city, date: stop.date, spot: spot, quality: quality, legendary: legendaryCatch });
1902 // TT-TAPES: hand the taped night to the play page, which keeps a playlist of
1903 // every real show the player has taped across runs.
1904 if (HAS_DOM && stop.identifier) {
1905 try {
1906 window.dispatchEvent(new CustomEvent("tt:taped", { detail: { identifier: stop.identifier, venue: stop.venue, city: stop.city,
1907 ymd: stop.ymd, date: stop.date, year: TOUR_YEAR, band: TOUR_BAND || "Grateful Dead", quality: quality, legendary: !!legendaryCatch } }));
1908 } catch (e) {}
1909 }
1910 if (extra && extra.length) { for (var xi = 0; xi < extra.length; xi++) lines.push(extra[xi]); }
1911 }
1912 var boost = (stop.legendary ? rnd(12, 20) : rnd(6, 12)) + (S.buff.showVibes || 0) + (crewHas(S, "bird") ? 2 : 0) + crewBonus(S) * 2;
1913 S.vibes = clamp(S.vibes + boost, 0, CAP.vibes);
1914 S.showsAttended += 1;
1915 S.taped = true;
1916 setlistLines(stop).forEach(function (l) { lines.push(l); });
1917 lines.push("The show itself is transcendent. +" + boost + " Vibes.");
1918 S.mini = null;
1919 if (checkDeath()) return;
1920 if (stop.finale) { finishTourLeg({ title: title, lines: lines }); return; }
1921 showResult(title, lines, "lot");
1922 }
1923
1924 function attendOnly() {
1925 var stop = STOPS[S.stop];
1926 var boost = (stop.legendary ? rnd(12, 20) : rnd(6, 12)) + (S.buff.showVibes || 0) + (crewHas(S, "bird") ? 2 : 0) + crewBonus(S) * 2;
1927 S.vibes = clamp(S.vibes + boost, 0, CAP.vibes);
1928 S.showsAttended += 1; S.taped = true;
1929 addLog(S, "Caught " + stop.venue + " without recording. Just danced.", "good");
1930 var lines = ["You leave the deck in the van and just... dance. Sometimes that's the point."].concat(setlistLines(stop), ["+" + boost + " Vibes."]);
1931 if (stop.finale) { finishTourLeg({ title: "THE FINALE", lines: lines }); return; }
1932 showResult("THE SHOW", lines, "lot");
1933 }
1934
1935 function rest() {
1936 S.vibes = clamp(S.vibes + 12, 0, CAP.vibes);
1937 S.grub = clamp(S.grub - crewAlive(S), 0, CAP.grub);
1938 S.day += 1;
1939 addLog(S, "You take a day to just be. +12 Vibes.", "good");
1940 showResult("A DAY OFF", ["The crew sleeps in, does laundry in a gas-station sink, and remembers why they're out here.", "+12 Vibes, the crew ate through some grub."], "lot");
1941 }
1942
1943 // ---------- sightseeing (spend a spare day on a real roadside attraction) ----------
1944 // Real detours keyed to where you are -- by US state, or by country abroad. You can
1945 // only sightsee when the pace bought you slack days before the show (S.legSlack);
1946 // each trip spends a day and gives Vibes + a little Lot Cred (a good story).
1947 var SIGHTS = {
1948 NY: ["Niagara Falls", "the Catskills", "a diner that claims Kerouac slept there"],
1949 CT: ["the Gillette Castle", "a covered bridge nobody can date"],
1950 MA: ["Walden Pond", "the Salem witch-trial markers"],
1951 PA: ["Gettysburg at dawn", "the world's largest crayon"],
1952 NJ: ["the boardwalk off-season", "a diner shaped like a coffee pot"],
1953 MD: ["the Chesapeake at low tide", "a crab shack with no sign"],
1954 DC: ["the Lincoln Memorial at 3 a.m.", "the free Smithsonian"],
1955 VA: ["a Blue Ridge overlook", "a battlefield gone quiet"],
1956 NC: ["the Blue Ridge Parkway", "a barbecue joint older than the state road"],
1957 GA: ["a peach stand the size of a barn", "Stone Mountain"],
1958 FL: ["a roadside gator farm", "the world's smallest police station"],
1959 OH: ["a giant roadside basket", "Lake Erie in a fog"],
1960 MI: ["a Great Lakes lighthouse", "the world's largest tire"],
1961 IL: ["a Route 66 giant holding a hot dog", "the Cahokia Mounds"],
1962 MN: ["Paul Bunyan and Babe", "a ten-thousandth lake"],
1963 NE: ["Carhenge", "the world's largest ball of stamps"],
1964 MO: ["the Gateway Arch", "a cave with a neon sign"],
1965 RI: ["the cliff walk in Newport", "a clam shack on stilts"],
1966 CO: ["a Rocky Mountain pass", "a hot spring nobody marked on a map"],
1967 MT: ["a Big Sky overlook", "a ghost town off the two-lane"],
1968 WA: ["a Cascades viewpoint", "the world's largest frying pan"],
1969 CA: ["a redwood you can drive through", "the Winchester Mystery House", "a pier at golden hour"],
1970 OK: ["a Route 66 roadside museum", "the Blue Whale of Catoosa"],
1971 KS: ["the world's largest ball of twine", "a wheat field at dusk"],
1972 England: ["Stonehenge in the mist", "a pub older than the country you came from"],
1973 Germany: ["a castle on the Rhine", "an autobahn rest stop with real food"],
1974 Netherlands: ["a canal at dawn", "a windmill that still turns"]
1975 };
1976 function sightRegion(city) {
1977 var parts = String(city || "").split(",");
1978 var tail = (parts[parts.length - 1] || "").trim();
1979 return SIGHTS[tail] ? tail : (SIGHTS[tail.replace(/\..*/, "")] ? tail : "CA");
1980 }
1981 // Each named sight plays out as a small, distinctly-flavored sidequest rather than
1982 // an identical vibes bump every time. A stable hash picks the same flavor for the
1983 // same sight name every time it comes up, so "Niagara Falls" always plays the same
1984 // way while different sights across the map still feel different from each other.
1985 var SIDEQUEST_FLAVORS = [
1986 { tag: "scenic", verb: "just stand there and take it in",
1987 text: "The crew loses track of time looking at it. Nobody checks a watch once.",
1988 effect: function (s) { var v = rnd(9, 15) + crewBonus(s); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "+" + v + " Vibes."; } },
1989 { tag: "history", verb: "get the whole story out of a local",
1990 text: "Somebody in the crew turns out to know entirely too much history, and won't stop.",
1991 effect: function (s) { var cr = rnd(5, 9); s.cred = clamp(s.cred + cr, 0, 100); var v = rnd(2, 5); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "+" + cr + " Lot Cred, +" + v + " Vibes."; } },
1992 { tag: "food", verb: "hunt down the best food in town",
1993 text: "You find the best roadside food of the entire tour, completely by accident.",
1994 effect: function (s) { var g = rnd(6, 10); s.grub = clamp(s.grub + g, 0, CAP.grub); var v = rnd(3, 6); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "+" + g + " Grub, +" + v + " Vibes."; } },
1995 { tag: "weird", verb: "lean all the way into the weirdness",
1996 text: "Absolutely worth the detour, and also deeply, wonderfully strange.",
1997 effect: function (s) { var cr = rnd(3, 7); s.cred = clamp(s.cred + cr, 0, 100); var v = rnd(6, 10); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "+" + cr + " Lot Cred, +" + v + " Vibes."; } },
1998 { tag: "hustle", verb: "work an angle while you're there",
1999 text: "Somebody in the crew finds a way to turn the tourist trap into a few bucks.",
2000 effect: function (s) { var c = rnd(6, 16); s.cash += c; var v = rnd(1, 4); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "+" + money(c) + ", +" + v + " Vibes."; } },
2001 { tag: "find", verb: "poke around the gift shop and parking lot",
2002 text: "A local swaps you something useful off their truck for a good story about the tour.",
2003 effect: function (s) { var part = pick(["disco_ball", "cb_radio", "roof_rack", "spare_tire"]); s.stash.push(part); var v = rnd(2, 5); s.vibes = clamp(s.vibes + v, 0, CAP.vibes); return "A " + gearName(part) + " into your stash, +" + v + " Vibes."; } }
2004 ];
2005 function hashStr(str) {
2006 var h = 0;
2007 for (var i = 0; i < str.length; i++) { h = (h * 31 + str.charCodeAt(i)) | 0; }
2008 return Math.abs(h);
2009 }
2010 function sightFlavor(name) { return SIDEQUEST_FLAVORS[hashStr(name) % SIDEQUEST_FLAVORS.length]; }
2011 function sightOptions() {
2012 var region = sightRegion(STOPS[S.stop].city);
2013 var names = shuffle((SIGHTS[region] || SIGHTS.CA).slice()).slice(0, 3);
2014 return names.map(function (name) { return { name: name, flavor: sightFlavor(name) }; });
2015 }
2016 function doSightsee() {
2017 if (!S.legSlack || S.legSlack < 1) { showResult("NO TIME", ["The show's tonight -- no spare day to wander. Push a mellower pace next leg to buy time."], "lot"); return; }
2018 S.sightOptions = sightOptions();
2019 S.view = "sightsee"; render();
2020 }
2021 function chooseSightsee(idx) {
2022 var opt = S.sightOptions && S.sightOptions[idx];
2023 if (!opt) return;
2024 S.legSlack -= 1; S.day += 1;
2025 var line = opt.flavor.effect(S);
2026 addLog(S, "Took the crew to " + opt.name + ". " + line, "good");
2027 S.sightOptions = null;
2028 showResult("A SPARE DAY: " + opt.name.toUpperCase(), [
2029 "You've got time before the show, so you take the crew to " + opt.name + " to " + opt.flavor.verb + ".",
2030 opt.flavor.text, line
2031 ], "lot");
2032 }
2033
2034 // ---------- end game ----------
2035 function computeScore() {
2036 return Math.round(S.tapes * 8 + S.showsAttended * 15 + S.legendaryTapes * DNA.legend + S.vibes + S.cred + S.cash / 5 + S.miles / 40);
2037 }
2038 // A finale night either ends the run (the usual case) or, mid-campaign, rolls
2039 // straight into next year's tour with the same character, crew, and van.
2040 function finishTourLeg(finaleResult) {
2041 if (S.campaign && S.campaign.index < S.campaign.years.length - 1) { advanceCampaignYear(finaleResult); return; }
2042 endGame(true, null, finaleResult);
2043 }
2044 function advanceCampaignYear(finaleResult) {
2045 var justFinished = tourByYear(TOUR_YEAR);
2046 S.campaign.index += 1;
2047 var nextYear = S.campaign.years[S.campaign.index];
2048 setTour(nextYear);
2049 S.tour = nextYear;
2050 S.stop = 0; S.hasTicket = true; S.taped = false; S.missedShow = false; S.legSlack = 0;
2051 S.queue = []; S.mode = null; S.currentEvent = null;
2052 S.peaked = false; S.peakQueued = false; S.peakData = null;
2053 rollMarket(S);
2054 var next = tourByYear(nextYear);
2055 addLog(S, "You wrap the " + justFinished.label + " and roll straight into the " + next.label + ".", "note");
2056 var lines = (finaleResult && finaleResult.lines ? finaleResult.lines.slice() : []).concat([
2057 "That's the " + justFinished.label + " wrapped -- year " + (S.campaign.index + 1) + " of " + S.campaign.years.length + ". The " + next.label + " starts now."
2058 ]);
2059 showResult("YEAR " + S.campaign.index + " DONE: ON TO " + next.year, lines, "lot");
2060 }
2061 function endGame(won, cause, finaleResult) {
2062 S.won = won; S.score = computeScore(); S.cause = cause;
2063 Sound[won ? "win" : (cause === "dosed" ? "dosed" : "bad")]();
2064 S.pendingEnd = { won: won, cause: cause, finale: finaleResult || null };
2065 // persist
2066 var crewNames = S.crew.map(function (c) { return c.name + (c.ok ? "" : " (lost)"); }).join(", ");
2067 var campaignYears = (S.campaign && S.campaign.years) || [TOUR_YEAR];
2068 var wonCause = campaignYears.length > 1 ? "completed a " + campaignYears.length + "-year run" : "completed the tour";
2069 var rec = { name: S.roleName, role: S.role, outcome: won ? "won" : "lost", cause: won ? wonCause : causeText(cause),
2070 score: S.score, tapes: S.tapes, legendary: S.legendaryTapes, shows: S.showsAttended, miles: S.miles, day: S.day,
2071 year: campaignYears[0], crew: crewNames };
2072 if (TOUR_BAND) rec.band = TOUR_BAND; // the shared Hall mixes runs from every band
2073 saveRun(rec);
2074 // TT-SHARE: hand the finished run to the play page, which offers the shared leaderboard.
2075 if (HAS_DOM) { try { window.dispatchEvent(new CustomEvent("tt:runend", { detail: rec })); } catch (e) {} }
2076 saveLastSetup(S); // remember this setup so "Quick Start" can replay it next time
2077 clearSave(); // the trip is over -- no in-progress run to resume
2078 // unlock any milestones this run earned; the end screen celebrates the new ones
2079 S.newAchievements = evaluateAchievements(S, won);
2080 S.view = won ? "win" : "gameover"; render();
2081 }
2082 function causeText(cause) {
2083 if (cause === "dosed") return "got dosed into another dimension";
2084 if (cause === "alone") return "lost the whole crew";
2085 return cause || "the road took its toll";
2086 }
2087
2088 // ---------- persistence ----------
2089 // The Hall of the Fallen persists to the serverless /api/hall endpoint when a
2090 // backing store is configured (see api/hall.js + Vercel KV / Upstash Redis).
2091 // If that endpoint is unavailable -- a static-only deploy, no store configured,
2092 // or the player is offline -- we fall back to this browser's localStorage so
2093 // each player still keeps their own runs. hallBackend tracks which mode we're
2094 // in: null = unknown, true = shared server hall, false = local-only.
2095 var HALL_LOCAL_KEY = "terrapin_hall_v1";
2096 var HALL_MAX = 500;
2097 var hallBackend = null;
2098 function readLocalHall() {
2099 try {
2100 var raw = window.localStorage.getItem(HALL_LOCAL_KEY);
2101 var runs = raw ? JSON.parse(raw) : [];
2102 return Array.isArray(runs) ? runs : [];
2103 } catch (e) { return []; }
2104 }
2105 function writeLocalHall(runs) {
2106 try { window.localStorage.setItem(HALL_LOCAL_KEY, JSON.stringify(runs.slice(-HALL_MAX))); } catch (e) {}
2107 }
2108 function loadHall(cb) {
2109 if (!HAS_DOM) { hallBackend = false; HALL = []; if (cb) cb(); return; }
2110 fetch("api/hall").then(function (r) {
2111 if (!r.ok) throw new Error("hall backend unavailable");
2112 return r.json();
2113 }).then(function (d) {
2114 hallBackend = true; HALL = d.runs || []; if (cb) cb();
2115 }).catch(function () {
2116 hallBackend = false; HALL = readLocalHall(); if (cb) cb();
2117 });
2118 }
2119 function saveRun(rec) {
2120 // Known local-only mode (or headless tests): skip the network round-trip.
2121 if (hallBackend === false || !HAS_DOM) {
2122 var runs = readLocalHall(); runs.push(rec); runs = runs.slice(-HALL_MAX);
2123 writeLocalHall(runs); HALL = runs; return;
2124 }
2125 fetch("api/hall", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(rec) })
2126 .then(function (r) { if (!r.ok) throw new Error("hall post failed"); return r.json(); })
2127 .then(function (d) { hallBackend = true; HALL = d.runs || HALL; })
2128 .catch(function () {
2129 // Backend absent or failed: keep the run locally so it isn't lost.
2130 hallBackend = false;
2131 var runs = readLocalHall(); runs.push(rec); runs = runs.slice(-HALL_MAX);
2132 writeLocalHall(runs); HALL = runs;
2133 });
2134 }
2135
2136 // ---------- achievements ----------
2137 // Milestones a player unlocks across ALL their runs (not just the current one).
2138 // Each check() gets a small read-only snapshot of a finished run; the unlocked
2139 // set persists to this browser's localStorage, right alongside the Hall. Under
2140 // Node (the test harness) there's no localStorage, so the set just lives in
2141 // memory for the process -- the unlock logic is identical either way.
2142 var ACH_KEY = "terrapin_achievements_v1";
2143 var ACHIEVEMENTS = [
2144 { id: "rolling", name: "The Wheels Are Rolling", icon: "🚛", desc: "Catch your first show of the tour.",
2145 check: function (c) { return c.showsAttended >= 1; } },
2146 { id: "peaked", name: "Ripple in Still Water", icon: "✨", desc: "Ride your character's once-per-run Peak.",
2147 check: function (c) { return c.peaked; } },
2148 { id: "generous", name: "Good Vibes, Shared", icon: "🤝", desc: "Keep the vibes high enough that a van-mate pools resources with you.",
2149 check: function (c) { return c.crewPooled; } },
2150 { id: "family", name: "One Big Family", icon: "👤", desc: "Finish the tour with your whole van-mate crew still aboard.",
2151 check: function (c) { return c.won && c.fullCrew; } },
2152 { id: "cornell", name: "Lightning in a Bottle", icon: "🎵", desc: "Capture any tour's legendary show clean.",
2153 check: function (c) { return c.legendaryTapes >= 1; } },
2154 { id: "archivist", name: "The Archivist", icon: "🎥", desc: "Come home with eight or more tapes in the case.",
2155 check: function (c) { return c.tapes >= 8; } },
2156 { id: "roaddog", name: "Long Haul", icon: "🛤", desc: "Log three thousand miles on the bus.",
2157 check: function (c) { return c.miles >= 3000; } },
2158 { id: "flush", name: "Shakedown Tycoon", icon: "💰", desc: "End a run holding two hundred dollars or more.",
2159 check: function (c) { return c.cash >= 200; } },
2160 { id: "blissed", name: "Fully Blissed", icon: "🌟", desc: "End a run with Vibes at 95 or above.",
2161 check: function (c) { return c.vibes >= 95; } },
2162 { id: "finisher", name: "The Whole Strange Trip", icon: "★", desc: "Finish the tour, the Palladium all the way to Hartford.",
2163 check: function (c) { return c.won; } }
2164 ];
2165 var ACH_INDEX = {};
2166 ACHIEVEMENTS.forEach(function (a) { ACH_INDEX[a.id] = a; });
2167
2168 var UNLOCKED = null; // lazy { id: true } map, loaded once from storage
2169 function loadUnlocked() {
2170 if (UNLOCKED) return UNLOCKED;
2171 UNLOCKED = {};
2172 if (HAS_DOM) {
2173 try { var raw = window.localStorage.getItem(ACH_KEY); var o = raw ? JSON.parse(raw) : {}; if (o && typeof o === "object") UNLOCKED = o; } catch (e) {}
2174 }
2175 return UNLOCKED;
2176 }
2177 function saveUnlocked() {
2178 if (!HAS_DOM) return;
2179 try { window.localStorage.setItem(ACH_KEY, JSON.stringify(UNLOCKED)); } catch (e) {}
2180 }
2181 // A finished-run snapshot for the checks -- read-only facts, no live state.
2182 function runContext(s, won) {
2183 return {
2184 won: won, showsAttended: s.showsAttended, tapes: s.tapes, legendaryTapes: s.legendaryTapes,
2185 peaked: !!s.peaked, miles: s.miles, cash: s.cash, vibes: s.vibes, cred: s.cred, role: s.role,
2186 fullCrew: s.crew.every(function (x) { return x.ok; }),
2187 crewPooled: !!(s.flags && s.flags.crewPooled)
2188 };
2189 }
2190 // Evaluate every achievement against this run, unlock any newly earned, persist,
2191 // and return the freshly unlocked ones (for the end screen to celebrate).
2192 function evaluateAchievements(s, won) {
2193 var ctx = runContext(s, won);
2194 var have = loadUnlocked();
2195 var fresh = [];
2196 ACHIEVEMENTS.forEach(function (a) {
2197 if (!have[a.id] && a.check(ctx)) { have[a.id] = true; fresh.push(a); }
2198 });
2199 if (fresh.length) saveUnlocked();
2200 return fresh;
2201 }
2202 function isUnlocked(id) { return !!loadUnlocked()[id]; }
2203 function unlockedCount() { return ACHIEVEMENTS.filter(function (a) { return isUnlocked(a.id); }).length; }
2204
2205 // ---------- save / resume ----------
2206 // A run in progress is snapshotted to localStorage whenever the player is back
2207 // at the lot -- a stable hub with no in-flight event / mini-game / driving state
2208 // -- so a refresh or a closed tab can pick the trip back up. Transient,
2209 // function-bearing fields are stripped so the snapshot is plain JSON.
2210 var SAVE_KEY = "terrapin_save_v1";
2211 var SAVE_DROP = ["currentEvent", "mini", "drive", "peakData", "peakQueued", "result",
2212 "pendingEnd", "newAchievements", "crewPool", "crewFind", "crewQuit", "vanReturn", "modNote",
2213 "crewNote", "queue", "mode", "blogPosts", "blogFetched", "haggle", "sightOptions", "restoreScrollTo"];
2214 function serializeSave(s) {
2215 var o = {};
2216 Object.keys(s).forEach(function (k) { if (SAVE_DROP.indexOf(k) === -1) o[k] = s[k]; });
2217 o.view = "lot";
2218 return o;
2219 }
2220 function saveGame(s) {
2221 if (!HAS_DOM) return;
2222 try { window.localStorage.setItem(SAVE_KEY, JSON.stringify(serializeSave(s))); } catch (e) {}
2223 }
2224 function clearSave() {
2225 if (!HAS_DOM) return;
2226 try { window.localStorage.removeItem(SAVE_KEY); } catch (e) {}
2227 }
2228 function loadSave() {
2229 if (!HAS_DOM) return null;
2230 try {
2231 var raw = window.localStorage.getItem(SAVE_KEY);
2232 var o = raw ? JSON.parse(raw) : null;
2233 return (o && typeof o === "object" && o.role && typeof o.stop === "number") ? o : null;
2234 } catch (e) { return null; }
2235 }
2236 function hasSave() {
2237 var o = loadSave();
2238 if (!o || o.won) return false;
2239 var t = tourByYear(o.tour) || tourByYear(DEFAULT_TOUR_YEAR);
2240 return o.stop < t.stops.length;
2241 }
2242 // Restore a snapshot into the live game, clearing any transient state and
2243 // re-deriving the buff/CAP object from the saved van parts.
2244 function applySave(o) {
2245 S = o;
2246 setTour(o.tour || DEFAULT_TOUR_YEAR); // re-point STOPS/TOUR_YEAR to the saved tour
2247 S.result = null; S.currentEvent = null; S.mini = null; S.drive = null;
2248 S.mode = null; S.queue = S.queue || []; S.peakQueued = false;
2249 recomputeBuffs(S);
2250 S.view = "lot";
2251 return S;
2252 }
2253
2254 // ---------- quick start (reuse the last completed run's character creation) ----------
2255 // Just the choices made at the START of a trip -- role, crew, mods, van cosmetics --
2256 // not anything bought along the way. Lets "Quick Start" skip role/crew/builder
2257 // entirely and land straight at the lot with the same setup as last time.
2258 var LAST_SETUP_KEY = "terrapin_last_setup_v1";
2259 function setupFromState(s) {
2260 return {
2261 role: s.role, tour: s.tour,
2262 crewPick: s.crewPick.map(function (p) { return { id: p.id, name: p.name, on: p.on }; }),
2263 pickMods: s.pickMods.slice(),
2264 vanStyle: s.vanStyle, vanName: s.vanName
2265 };
2266 }
2267 function saveLastSetup(s) {
2268 if (!HAS_DOM) return;
2269 try { window.localStorage.setItem(LAST_SETUP_KEY, JSON.stringify(setupFromState(s))); } catch (e) {}
2270 }
2271 function loadLastSetup() {
2272 if (!HAS_DOM) return null;
2273 try {
2274 var raw = window.localStorage.getItem(LAST_SETUP_KEY);
2275 var o = raw ? JSON.parse(raw) : null;
2276 return (o && typeof o === "object" && o.role && Array.isArray(o.crewPick)) ? o : null;
2277 } catch (e) { return null; }
2278 }
2279 function hasLastSetup() { return !!loadLastSetup(); }
2280 // Replay a saved character-creation setup onto a brand-new game -- role, crew
2281 // (on/off + names), mods, van cosmetics -- then skip straight to the lot, same
2282 // as finishing the builder screen normally.
2283 function applySetup(setup) {
2284 var role = ROLES.filter(function (r) { return r.id === setup.role; })[0] || ROLES[0];
2285 setTour(setup.tour || DEFAULT_TOUR_YEAR);
2286 newGame(role);
2287 setup.crewPick.forEach(function (p) {
2288 var live = S.crewPick.filter(function (x) { return x.id === p.id; })[0];
2289 if (live) { live.on = !!p.on; live.name = p.name; }
2290 });
2291 rebuildCrew(S);
2292 S.pickMods = (setup.pickMods || []).filter(function (id) { return MODS.some(function (m) { return m.id === id; }); }).slice(0, MOD_SLOTS);
2293 applyMods();
2294 S.vanStyle = setup.vanStyle || S.vanStyle;
2295 S.vanName = setup.vanName || "";
2296 finalizeBuild();
2297 return S;
2298 }
2299 function quickStart() {
2300 var setup = loadLastSetup(); if (!setup) return;
2301 applySetup(setup);
2302 }
2303 function resumeSavedGame() { var o = loadSave(); if (o) { applySave(o); render(); } }
2304
2305 // ---------- result card helper ----------
2306 // A result card is a brief "here's what happened" interstitial (buying something,
2307 // working the lot, an event outcome...). Remember where the player was scrolled to
2308 // before it popped up so continuing past it can put them right back there, instead
2309 // of everyone getting yanked to the top of a long vendor list every time they buy
2310 // one item.
2311 function showResult(title, lines, next) {
2312 if (HAS_DOM) savedScrollY = window.scrollY;
2313 S.result = { title: title, lines: lines, next: next };
2314 S.view = "result"; render();
2315 }
2316 function continueResult() {
2317 var next = S.result ? S.result.next : "lot";
2318 S.result = null;
2319 if (S.mode === "leg") { resumeLeg(); return; }
2320 S.restoreScrollTo = savedScrollY;
2321 S.view = next; render();
2322 }
2323
2324 // ---------- rendering ----------
2325 var app;
2326 // Build the HTML for the current view. Pure (no DOM), so the test harness can
2327 // render every screen headlessly and catch view bugs without a browser.
2328 function viewHtml() {
2329 var v = S ? S.view : "title";
2330 switch (v) {
2331 case "title": return viewTitle();
2332 case "tours": return viewTours();
2333 case "campaign": return viewCampaign();
2334 case "role": return viewRole();
2335 case "crew": return viewCrew();
2336 case "builder": return viewBuilder();
2337 case "van": return viewVan();
2338 case "stash": return viewStash();
2339 case "library": return viewLibrary();
2340 case "peak": return viewPeak();
2341 case "lot": return viewLot();
2342 case "confirm-drive": return viewConfirmDrive();
2343 case "work": return viewWork();
2344 case "shakedown": return viewShakedown();
2345 case "haggle": return viewHaggle();
2346 case "tape": return viewTape();
2347 case "event": return viewEvent();
2348 case "result": return viewResultCard();
2349 case "arrive": return viewArrive();
2350 case "sightsee": return viewSightsee();
2351 case "driving": return viewDriving();
2352 case "mini": return viewMini();
2353 case "gameover": return viewGameover();
2354 case "win": return viewWin();
2355 case "hall": return viewHall();
2356 case "ach": return viewAchievements();
2357 case "about": return viewAbout();
2358 default: return viewTitle();
2359 }
2360 }
2361 var lastRenderedView = null;
2362 var savedScrollY = 0;
2363 function render() {
2364 var html = viewHtml();
2365 if (!HAS_DOM) return; // headless: state is updated, nothing to paint
2366 if (S && STOPS && STOPS[S.stop]) prefetchSetlist(STOPS[S.stop]);
2367 if (!app) app = document.getElementById("app");
2368 app.innerHTML = bandify(html);
2369 var v = S ? S.view : "title";
2370 if (S && typeof S.restoreScrollTo === "number") { window.scrollTo(0, S.restoreScrollTo); S.restoreScrollTo = null; }
2371 // Only jump to the top when the screen actually changes -- an in-place update
2372 // (buying gear, toggling a mod, installing a van part...) leaves scroll alone.
2373 else if (v !== lastRenderedView) { window.scrollTo(0, 0); }
2374 lastRenderedView = v;
2375 if (v === "driving") startDriveAnim();
2376 if (v === "mini") startMiniLoop();
2377 // Snapshot the run whenever the player is at the lot hub (a clean save point).
2378 if (S && v === "lot") saveGame(S);
2379 updateTrailMap();
2380 updateMusicPanel();
2381 applyShortcutHints();
2382 }
2383
2384 // ---------- keyboard shortcuts ----------
2385 // The same ordered list of "pressable" elements backs both the 1-9 number-key
2386 // shortcuts and (when the option is on) the little [1] [2] ... badges next to
2387 // them, so a badge always fires the key it claims to.
2388 function shortcutableEls() {
2389 if (!HAS_DOM || !app) return [];
2390 var all = app.querySelectorAll("[data-action]");
2391 var out = [];
2392 for (var i = 0; i < all.length && out.length < 9; i++) { if (!all[i].disabled) out.push(all[i]); }
2393 return out;
2394 }
2395 function applyShortcutHints() {
2396 if (!HAS_DOM || !app) return;
2397 var old = app.querySelectorAll(".kbdhint");
2398 for (var j = 0; j < old.length; j++) old[j].parentNode.removeChild(old[j]);
2399 if (!OPTS.hints) return;
2400 var els = shortcutableEls();
2401 els.forEach(function (el, i) {
2402 var badge = document.createElement("span");
2403 badge.className = "kbdhint"; badge.textContent = String(i + 1);
2404 el.insertBefore(badge, el.firstChild);
2405 });
2406 }
2407 function onKeyDown(e) {
2408 if (!HAS_DOM || !S) return;
2409 var tag = (e.target && e.target.tagName) || "";
2410 if (tag === "INPUT" || tag === "TEXTAREA") { if (e.key === "Escape") e.target.blur(); return; }
2411 if (e.altKey || e.ctrlKey || e.metaKey) return;
2412 if (e.key === "Escape") {
2413 if (helpOpen) { helpOpen = false; updateHelp(); return; }
2414 if (optsOpen) { optsOpen = false; renderOptsPanel(); return; }
2415 return;
2416 }
2417 var k = e.key.toLowerCase();
2418 if (k === "m") { var mt = document.getElementById("map-toggle"); if (mt) mt.click(); return; }
2419 if (k === "o") { var ot = document.getElementById("opts-btn"); if (ot) ot.click(); return; }
2420 if (k === "h" || k === "?") { var ht = document.getElementById("help-toggle"); if (ht) ht.click(); return; }
2421 if (k === "s") { var st = document.getElementById("mute-btn"); if (st) st.click(); return; }
2422 if (k >= "1" && k <= "9") {
2423 var els = shortcutableEls();
2424 var el = els[parseInt(k, 10) - 1];
2425 if (el) { e.preventDefault(); el.click(); }
2426 }
2427 }
2428
2429 // status bar
2430 function statusBar() {
2431 var need = legNeeds(S);
2432 function cell(icon, label, val, low) { return '<span>' + icon + ' ' + label + ' <b class="' + (low ? "lo" : "") + '">' + val + '</b></span>'; }
2433 var gasLow = need && S.gas < need.gas;
2434 return '<div class="status">' +
2435 cell("💵", "", money(S.cash), S.cash <= 5) +
2436 cell("⛽", "Gas", S.gas, gasLow) +
2437 cell("🍝", "Grub", S.grub, S.grub <= 3) +
2438 cell("🚛", "Van", vanLabel(S.van), S.van < 30) +
2439 cell("🌟", "Vibes", vibesLabel(S.vibes), S.vibes < 25) +
2440 cell("🎥", "Tapes", S.tapes + (S.legendaryTapes ? " (" + S.legendaryTapes + "★)" : ""), false) +
2441 cell("🎟", "Cred", S.cred, false) +
2442 '</div>';
2443 }
2444
2445 // route tracker (dynamic map)
2446 function routeTracker() {
2447 var parts = [];
2448 for (var i = 0; i < STOPS.length; i++) {
2449 var st = STOPS[i];
2450 var cls, mark;
2451 if (i < S.stop) { cls = "played"; mark = "✓"; }
2452 else if (i === S.stop) { cls = "here"; mark = "[BUS]"; }
2453 else if (st.legendary) { cls = "legend-star"; mark = "★"; }
2454 else { cls = "stop"; mark = "○"; }
2455 var tag = '<span class="' + cls + '">' + mark + ' ' + esc(st.city.split(",")[0]) + (st.legendary ? "*" : "") + '</span>';
2456 parts.push(tag);
2457 if (i < STOPS.length - 1) parts.push('<span class="dim"> ─' + STOPS[i + 1].miles + '─ </span>');
2458 }
2459 var need = legNeeds(S);
2460 var bar = "";
2461 if (need) {
2462 bar = '<p class="bar small">Next: <span class="cyan">' + esc(need.next.venue) + ", " + esc(need.next.city) + '</span> · ' +
2463 need.miles + ' mi · needs <span class="amber">' + need.gas + ' gas</span>, <span class="amber">' + need.grub + ' grub</span></p>';
2464 } else {
2465 bar = '<p class="bar small legend-star">This is the last stop -- the finale.</p>';
2466 }
2467 return collapsibleBox("route", "The Long Strange Route — leg " + Math.min(S.stop + 1, STOPS.length) + " of " + STOPS.length,
2468 '<p style="line-height:1.9">' + parts.join(" ") + '</p>' + bar);
2469 }
2470
2471 function logBox() {
2472 if (!S.log.length) return "";
2473 var rows = S.log.slice(0, 6).map(function (l) { return '<p class="' + l.c + '">· ' + esc(l.t) + '</p>'; }).join("");
2474 return '<div class="log"><div class="cap">The Road So Far</div>' + rows + '</div>';
2475 }
2476
2477 // ---- external links (open in a new tab; no data-action so the click
2478 // delegate ignores them and the browser just follows the href) ----
2479 var COFFEE_URL = "https://buymeacoffee.com/akamarkman";
2480 function coffeeLink() {
2481 return '<a class="coffee" href="' + COFFEE_URL + '" target="_blank" rel="noopener noreferrer">☕ Buy me a coffee</a>';
2482 }
2483 function extLink(href, label) {
2484 return '<a class="link" href="' + href + '" target="_blank" rel="noopener noreferrer">' + esc(label) + '</a>';
2485 }
2486 // A "challenge a buddy" mailto: link for the end screens -- no server round-trip,
2487 // just a pre-filled email the player's own mail client sends.
2488 function challengeMailto() {
2489 var url = HAS_DOM ? window.location.href.split("#")[0].split("?")[0] : "";
2490 var subject = "Beat my Terrapin Trail score: " + S.score;
2491 var body = "I just wrapped the " + TOUR_LABEL + " as " + S.roleName + " with a final score of " + S.score +
2492 " (" + S.tapes + " tapes, " + S.showsAttended + " shows, " + S.miles + " miles).\n\n" +
2493 "Think you can beat it? Give Terrapin Trail a shot:\n" + url;
2494 return "mailto:?subject=" + encodeURIComponent(subject) + "&body=" + encodeURIComponent(body);
2495 }
2496 function emailBuddyLink() {
2497 return '<p><a class="link" href="' + challengeMailto() + '">✉ Email this to a buddy — "beat my score"</a></p>';
2498 }
2499
2500 // ---- title ----
2501 function viewTitle() {
2502 var _band = (typeof TOUR_BAND !== "undefined") && TOUR_BAND;
2503 var _years = TOURS.map(function (t) { return t.year; }).sort(function (a, b) { return a - b; });
2504 var _yspan = _years.length ? (_years[0] + (_years[_years.length - 1] !== _years[0] ? "\u2013" + _years[_years.length - 1] : "")) : "";
2505 var _tag = _band ? ("~ terrapin trail: infinity edition · " + esc(_band) + " ~") : "~ terrapin trail: infinity edition · a long strange trip simulator ~ est. 2026 ~";
2506 var _intro1 = _band ? "You have a van, a tape deck, and a feeling." : "It's the 1970s. You have a van, a tape deck, and a feeling.";
2507 var _intro2 = _band ? ("The band is on tour, and so are you. This trip follows <span class=\"cyan\">" + esc(_band) + "</span>" + (_yspan ? " (" + _yspan + ")" : "") + ", its route built live from the Internet Archive \u2014 real venues, real dates. Pick a year and follow the caravan: tape every show, work every lot, and try not to get dosed into next Tuesday.") : "The band is on tour, and so are you. Pick a year — the Fillmores, Europe '72, the legendary Spring '77, Brent's '79 — and follow the caravan: tape every show, work every lot, and try not to get dosed into next Tuesday.";
2508 var _footer = _band ? ("A fan-made " + esc(_band) + " tribute, built from the Live Music Archive") : "A fan-made Grateful Dead tribute, not affiliated with the band";
2509 return '<span class="scroll-top"></span>' +
2510 '<div class="wordmark">' +
2511 '<pre class="td-word" aria-label="Terrapin Trail: Infinity Edition">' + LOGO + '</pre>' +
2512 '<span class="edition2" aria-hidden="true">∞</span></div>' +
2513 '<p class="dim center">' + _tag + '</p>' +
2514 '<hr><p>' + _intro1 + '</p>' +
2515 '<p>' + _intro2 + '</p>' +
2516 '<div class="titlemenu">' +
2517 (hasSave() ? btn("resume", "▶ Continue your trip", "tile primary") : "") +
2518 btn("go-tours", "🌟 New Trip", "tile primary") +
2519 (hasLastSetup() ? btn("quick-start", "⚡ Quick Start (last setup)", "tile") : "") +
2520 btn("go-hall", "🏆 Hall of the Fallen", "tile") +
2521 btn("go-ach", "✨ Badges & Milestones", "tile") +
2522 btn("go-about", "📜 Liner Notes", "tile") +
2523 '</div>' +
2524 '<p class="dim small">' + _footer + '</p>';
2525 }
2526
2527 function viewAbout() {
2528 return '<span class="scroll-top"></span><h3>About Terrapin Trail</h3>' +
2529 '<p>An affectionate send-up of Oregon Trail, following the Grateful Dead\'s real ' +
2530 'tours across the decade — ten playable years, from the 1970 Fillmores to Europe \'72 ' +
2531 'to the legendary Spring \'77 run to Brent\'s 1979 debut. Every stop is an actual show.</p>' +
2532 '<p>Keep the crew fed, the van alive, and the Vibes up. Trade on Shakedown Street, ' +
2533 'score a miracle ticket, tape every show — and don\'t let one bad burrito end it all.</p>' +
2534 '<div class="box"><div class="cap">The long, strange origin</div>' +
2535 '<p>It began years back as a project over at ' +
2536 extLink("https://deadbased.tumblr.com/", "deadbased.tumblr.com") + '. The original ' +
2537 'traced the tour on interactive maps that went dark when Google shut down the platform ' +
2538 'they ran on. This edition rebuilds it from scratch.</p></div>' +
2539 aboutBlog() +
2540 '<div class="box"><div class="cap">Hey, who made this?</div>' +
2541 '<p>Made by ' + extLink("https://akamarkman.com", "akamarkman.com") +
2542 ', years back during a virtual internship with ' + extLink("https://archive.org", "archive.org") + '.</p>' +
2543 '<p class="dim small">Licensed ' +
2544 extLink("https://creativecommons.org/licenses/by/4.0/", "CC BY 4.0") +
2545 ' — free to share, remix, and use commercially with credit. A fan-made tribute, not affiliated with the band.</p>' +
2546 '<p class="coffeewrap">' + coffeeLink() + '</p></div>' +
2547 '<div class="choices" style="max-width:280px">' + btn("go-title", "Back", "choice") + '</div>';
2548 }
2549 // The Terrapin Trail dev posts, pulled live from akamarkman's blog (see the About
2550 // fetch below). Renders from S.blogPosts: null = still loading, [] / array = the
2551 // posts, false = couldn't load (falls back to a direct link to the tag).
2552 var BLOG_TAG_URL = "https://akamarkman.blogspot.com/search/label/TerrapinTrail";
2553 // The post body comes from a third-party feed, so before injecting it as HTML we
2554 // strip active/dangerous markup (scripts, embeds, inline handlers, javascript:
2555 // URLs). The source is the maker's own blog, but this keeps a stray or malicious
2556 // post from running code on the game's page.
2557 function sanitizeBlogHtml(html) {
2558 return String(html)
2559 .replace(/<\s*(script|style|iframe|object|embed|form)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
2560 .replace(/<\s*(script|style|iframe|object|embed|form|link|meta|base)\b[^>]*\/?>/gi, "")
2561 .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
2562 .replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1="#"');
2563 }
2564 function blogPostLink(p) {
2565 return '<a class="link" href="' + esc(p.url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title) + '</a>' +
2566 (p.date ? ' <span class="dim small">· ' + esc(p.date) + '</span>' : '');
2567 }
2568 function aboutBlog() {
2569 var posts = S && S.blogPosts;
2570 var body;
2571 if (posts === false) {
2572 body = '<p class="dim small">Couldn\'t load it right now — read it straight from the source: ' +
2573 extLink(BLOG_TAG_URL, "the Terrapin Trail posts ↗") + '.</p>';
2574 } else if (!posts) {
2575 body = '<p class="dim small">Loading the latest from the blog…</p>';
2576 } else if (!posts.length) {
2577 body = '<p class="dim small">Nothing posted yet. ' + extLink(BLOG_TAG_URL, "Check the blog ↗") + '</p>';
2578 } else {
2579 var latest = posts[0];
2580 body = '<p class="blogpost">' + blogPostLink(latest) + '</p>' +
2581 '<div class="blogbody">' + sanitizeBlogHtml(latest.body) + '</div>' +
2582 '<p class="small">' + extLink(latest.url, (latest.full ? "Read it on the blog ↗" : "Read the full post ↗")) + '</p>';
2583 if (posts.length > 1) {
2584 body += '<hr><p class="small dim">More posts</p>' +
2585 posts.slice(1, 5).map(function (p) { return '<p class="blogpost">' + blogPostLink(p) + '</p>'; }).join("");
2586 }
2587 }
2588 return '<div class="box"><div class="cap">From the dev blog</div>' + body + '</div>';
2589 }
2590 // Pull the posts tagged "TerrapinTrail" from akamarkman's Blogger. Blogger feeds
2591 // don't send CORS headers, so we use their JSONP variant (alt=json-in-script +
2592 // callback) by injecting a <script>. Runs once per session; browser-only. Any
2593 // failure (network, or a private/permission-locked blog returning 401) falls back
2594 // to S.blogPosts = false, which shows a direct link to the tag instead.
2595 // The "default" projection carries the post body: content.$t when the blog's feed
2596 // is set to Full, or a truncated summary.$t when it's set to Short. We render
2597 // whichever is available, so the About page upgrades to the full text on its own
2598 // once the feed is switched to Full.
2599 var BLOG_FEED = "https://akamarkman.blogspot.com/feeds/posts/default/-/TerrapinTrail?alt=json-in-script&max-results=5&callback=";
2600 function fetchBlogPosts() {
2601 if (!HAS_DOM || !S || S.blogFetched) return;
2602 S.blogFetched = true;
2603 S.blogPosts = null; // loading
2604 var cb = "__ttBlog" + Math.floor(rand() * 1e9);
2605 var script = document.createElement("script");
2606 var timer = setTimeout(function () { finish(false); }, 8000);
2607 function cleanup() {
2608 clearTimeout(timer);
2609 try { delete window[cb]; } catch (e) { window[cb] = undefined; }
2610 if (script.parentNode) script.parentNode.removeChild(script);
2611 }
2612 function finish(val) { cleanup(); if (S) { S.blogPosts = val; if (S.view === "about") render(); } }
2613 window[cb] = function (data) {
2614 var out = [];
2615 try {
2616 var entries = (data && data.feed && data.feed.entry) || [];
2617 out = entries.map(function (e) {
2618 var alt = (e.link || []).filter(function (l) { return l.rel === "alternate"; });
2619 return {
2620 title: (e.title && e.title.$t) || "Untitled post",
2621 url: alt.length ? alt[0].href : BLOG_TAG_URL,
2622 date: ((e.published && e.published.$t) || "").slice(0, 10),
2623 body: (e.content && e.content.$t) || (e.summary && e.summary.$t) || "",
2624 full: !!(e.content && e.content.$t) // true when the feed served the whole post
2625 };
2626 });
2627 } catch (err) { out = []; }
2628 finish(out);
2629 };
2630 script.onerror = function () { finish(false); };
2631 script.src = BLOG_FEED + cb;
2632 document.head.appendChild(script);
2633 }
2634
2635 // ---- role select ----
2636 // ---- tour select (pick which year to ride) ----
2637 // Pick your tour: a horizontal timeline of years with one slide shown at a time,
2638 // instead of all ten tour cards stacked in a wall of text. S.tourPreview tracks
2639 // which year is on screen; it's just a browsing cursor -- the tour only actually
2640 // loads when "Ride this tour" fires pick-tour-<year>, same as before.
2641 function viewTours() {
2642 var previewYear = (S && S.tourPreview) || TOUR_YEAR;
2643 var t = tourByYear(previewYear) || TOURS[0];
2644 var idx = TOURS.indexOf(t);
2645 var leg = legendaryStopOf(t);
2646 var dots = TOURS.map(function (tt) {
2647 var on = tt.year === t.year;
2648 return '<button class="tldot' + (on ? " on" : "") + (tt.year === TOUR_YEAR ? " loaded" : "") +
2649 '" data-action="tourprev-' + tt.year + '" title="' + esc(tt.label) + '">' + tt.year + '</button>';
2650 }).join('<span class="tlline"></span>');
2651 var prev = TOURS[idx - 1], next = TOURS[idx + 1];
2652 return '<span class="scroll-top"></span><h3>Pick your tour</h3>' +
2653 '<p class="dim">' + (TOUR_BAND
2654 ? (TOURS.length > 1 ? "Every stop is a real " + esc(TOUR_BAND) + " show from the Live Music Archive. Step through the timeline to browse."
2655 : "A real " + esc(TOUR_BAND) + " run, built show by show from the Live Music Archive.")
2656 : 'Every year is a real Grateful Dead run — ten tours across the decade, from the Fillmores to the Wall of Sound to Brent\'s debut. Step through the timeline to browse.') + '</p>' +
2657 '<div class="tourline">' + dots + '</div>' +
2658 '<div class="box tourslide">' +
2659 '<div class="cap">' + esc(t.label) + (t.year === TOUR_YEAR ? ' <span class="cyan small">(loaded)</span>' : '') + '</div>' +
2660 '<p>' + esc(t.blurb) + '</p>' +
2661 '<p class="dim small">' + t.stops.length + ' shows · ' +
2662 esc(t.stops[0].city.split(",")[0]) + ' → ' + esc(t.stops[t.stops.length - 1].city.split(",")[0]) +
2663 ' · <span class="legend-star">★ ' + esc(leg.venue) + '</span></p>' +
2664 '<div class="choices" style="max-width:320px">' + btn("pick-tour-" + t.year, "Ride this tour ▶", "choice") + '</div>' +
2665 '</div>' +
2666 '<div class="choices tournav" style="max-width:320px">' +
2667 (prev ? btn("tourprev-" + prev.year, "◀ " + prev.year, "btn") : "") +
2668 (next ? btn("tourprev-" + next.year, next.year + " ▶", "btn") : "") +
2669 '</div>' +
2670 '<div class="choices" style="max-width:280px">' + btn("go-title", "Back", "choice") + '</div>';
2671 }
2672
2673 // How many years does your character play? Chains N consecutive tours (starting
2674 // at the one just picked) into one continuous campaign with the same character,
2675 // crew, and van -- the finale of each year rolls straight into the next one's
2676 // opener instead of ending the run.
2677 function viewCampaign() {
2678 var t = tourByYear(TOUR_YEAR);
2679 var maxLen = campaignYearsFrom(TOUR_YEAR, MAX_CAMPAIGN).length;
2680 var cards = [];
2681 for (var n = 1; n <= maxLen; n++) {
2682 var years = campaignYearsFrom(TOUR_YEAR, n);
2683 var labels = years.map(function (y) { return y; }).join(" → ");
2684 cards.push('<button class="col rolecard" data-action="camplen-' + n + '">' +
2685 '<span class="cap">' + (n === 1 ? "Just this one year" : n + " years back to back") + '</span>' +
2686 '<span class="rc-blurb">' + labels + '</span>' +
2687 '<span class="rc-note">' + years.map(function (y) { return esc(tourByYear(y).label); }).join(", then ") + '</span>' +
2688 '<span class="rc-go">Play it ▶</span></button>');
2689 }
2690 return '<span class="scroll-top"></span><h3>How many years does your head play?</h3>' +
2691 '<p class="dim">Starting from <span class="amber">' + esc(t.label) + '</span>, chain as many consecutive tours as you like ' +
2692 'into one run — same character, same crew, same van, straight through each finale into the next year\'s opener.' +
2693 (maxLen === 1 ? " (No more tours follow this one on the timeline, so it's a one-year run.)" : "") + '</p>' +
2694 '<div class="row">' + cards.join("") + '</div>' +
2695 '<div class="choices" style="max-width:280px">' + btn("go-tours", "Back", "choice") + '</div>';
2696 }
2697
2698 function viewRole() {
2699 var cards = ROLES.map(function (r) {
2700 return '<button class="col rolecard" data-action="pick-' + r.id + '">' +
2701 '<pre class="role-portrait" aria-hidden="true">' + esc(ROLE_ART[r.id] || "") + '</pre>' +
2702 '<span class="cap">' + esc(r.name) + '</span>' +
2703 '<span class="rc-blurb">' + esc(r.blurb) + '</span>' +
2704 '<span class="rc-note">' + esc(r.note) + '</span>' +
2705 '<span class="rc-go">Choose this head ▶</span></button>';
2706 }).join("");
2707 return '<span class="scroll-top"></span><h3>Who are you, man?</h3>' +
2708 '<p class="dim">Your past decides what you roll into the lot with. Tap a head to ride.</p>' +
2709 '<div class="row">' + cards + '</div>' +
2710 '<div class="choices" style="max-width:280px">' +
2711 btn("rand-role", "🎲 Surprise me", "choice") +
2712 btn("go-title", "Back", "choice") + '</div>';
2713 }
2714
2715 // ---- van-mate selection (pick who rides along + rename them) ----
2716 function viewCrew() {
2717 var onCount = S.crewPick.filter(function (p) { return p.on; }).length;
2718 var cards = S.crewPick.map(function (p) {
2719 var def = vanmateById(p.id);
2720 var rname = rosterName(S, p.id);
2721 var val = (p.name === undefined || p.name === null) ? rname : p.name;
2722 return '<div class="col box crewcard' + (p.on ? " on" : "") + '">' +
2723 '<div class="cap">' + (def.signature ? '⭐ ' : '') + esc(rname) + '</div>' +
2724 '<p class="dim small">' + esc(def.blurb) + '</p>' +
2725 '<p class="small">Trait: <span class="cyan">' + esc(p.trait) + '</span></p>' +
2726 '<p class="small">Perk: <span class="amber">' + esc(def.perk) + '</span></p>' +
2727 '<label class="crewnamelbl small">Call them:<br>' +
2728 '<input class="crewname" type="text" maxlength="24" data-crew="' + p.id + '" value="' + esc(val) + '"' + (p.on ? '' : ' disabled') + ' placeholder="' + esc(rname) + '"></label>' +
2729 btn("van-" + p.id, p.on ? "■ Aboard" : "□ Bench", "btn" + (p.on ? " buy" : "")) +
2730 '</div>';
2731 }).join("");
2732 var note = S.crewNote ? '<p class="amber small">' + esc(S.crewNote) + '</p>' : "";
2733 return '<span class="scroll-top"></span><h3>Who\'s riding with you?</h3>' +
2734 '<p class="dim">Pick your van-mates (' + CREW_MIN + '–' + CREW_MAX + ') and call them whatever you like. ' +
2735 'The ⭐ three anchor their own storylines out on the road.</p>' +
2736 '<p class="dim small">Whoever\'s aboard when you finish rides along on the Hall of the Fallen entry too.</p>' +
2737 '<p class="small">In the van: <span class="amber">' + onCount + '</span> of ' + CREW_MAX + '</p>' +
2738 '<div class="row">' + cards + '</div>' + note +
2739 '<div class="choices" style="max-width:360px">' +
2740 btn("rand-crew", "🎲 Randomize crew", "choice") +
2741 btn("crew-done", "That\'s my crew ▶", "choice") + '</div>';
2742 }
2743
2744 // ---- character builder (crew + mods + supplies + gear) ----
2745 function viewBuilder() {
2746 var crew = S.crew.map(function (c) { return '<p><span class="amber">' + esc(c.name) + '</span> <span class="dim small">— ' + esc(c.trait) + '</span></p>'; }).join("");
2747 var modCards = MODS.map(function (m) {
2748 var on = S.pickMods.indexOf(m.id) !== -1;
2749 return '<button class="modcard' + (on ? " on" : "") + '" data-action="mod-' + m.id + '">' +
2750 '<span class="modname">' + (on ? "■ " : "□ ") + esc(m.name) + '</span>' +
2751 '<span class="moddesc">' + esc(m.desc) + '</span></button>';
2752 }).join("");
2753 var gearRows = VAN_PARTS.concat(CONSUMABLES).map(gearRow).join("");
2754 var note = S.modNote ? '<p class="amber small">' + esc(S.modNote) + '</p>' : "";
2755 return '<span class="scroll-top"></span>' + statusBar() +
2756 '<h3>Build your head — ' + esc(S.roleName) + '</h3>' +
2757 '<div class="box"><div class="cap">Your crew in the van</div>' +
2758 '<p>You (the wheelman) — <span class="amber">Space Cowboy</span></p>' + crew +
2759 '<p>' + btn("go-crew", "Change your van-mates 👥", "btn") + '</p></div>' +
2760 '<div class="box"><div class="cap">Mods — pick up to ' + MOD_SLOTS + ' (' + S.pickMods.length + '/' + MOD_SLOTS + ' chosen)</div>' +
2761 '<div class="modgrid">' + modCards + '</div>' +
2762 '<p>' + btn("rand-mods", "🎲 Randomize mods", "btn") + '</p></div>' +
2763 '<div class="box"><div class="cap">Uncle\'s Head Shop — supplies</div>' +
2764 shopRow("gas", "Gas (5 units)", 4) +
2765 shopRow("grub", "Groceries (+8 Grub)", 5) +
2766 shopRow("blanks3", "Blank tapes (3-pack)", 4) +
2767 shopRow("repair", "Tune up the van (+15)", 12) +
2768 '</div>' +
2769 '<div class="box"><div class="cap">Gear & van parts (these go in your stash)</div>' + gearRows + '</div>' +
2770 stashSummary() + note +
2771 '<p>Cash left: <span class="amber">' + money(S.cash) + '</span></p>' +
2772 '<div class="choices" style="max-width:360px">' +
2773 btn("go-van", "Customize the van 🔧", "choice") +
2774 btn("start-trip", "Hit the road ▶", "choice") +
2775 '</div>';
2776 }
2777 function stashSummary() {
2778 var installed = Object.keys(S.vanmods).filter(function (sl) { return S.vanmods[sl]; })
2779 .map(function (sl) { var g = gearById(S.vanmods[sl]); return g ? (g.icon || "") + ' ' + esc(g.name) + ' <span class="dim small">(on the van)</span>' : ""; });
2780 var counts = {};
2781 S.stash.forEach(function (id) { counts[id] = (counts[id] || 0) + 1; });
2782 var loose = Object.keys(counts).map(function (id) { var g = gearById(id); return g ? (g.icon || "") + ' ' + esc(g.name) + (counts[id] > 1 ? ' x' + counts[id] : "") : esc(id); });
2783 var all = installed.concat(loose);
2784 if (!all.length) return '<div class="box"><div class="cap">Your stash</div><p class="dim">Empty. Buy some gear above, or find it out on the road.</p></div>';
2785 return '<div class="box"><div class="cap">Your stash</div><p style="line-height:1.8">' + all.join(' · ') + '</p></div>';
2786 }
2787
2788 // ---- van customization ----
2789 var VAN_SLOTS = [
2790 { slot: "engine", label: "Engine" }, { slot: "tank", label: "Fuel system" }, { slot: "tires", label: "Tires & suspension" },
2791 { slot: "sound", label: "Sound system" }, { slot: "deck", label: "Tape deck" }, { slot: "paint", label: "Paint job" },
2792 { slot: "rack", label: "Roof rack" }, { slot: "radio", label: "Radio" }, { slot: "charm", label: "Dashboard charm" },
2793 { slot: "curtains", label: "Curtains", decor: true }, { slot: "flag", label: "Flags & banners", decor: true },
2794 { slot: "stickers", label: "Bumper stickers", decor: true }, { slot: "hood", label: "Hood ornament", decor: true }
2795 ];
2796
2797 // Creative-mode paint themes: free, purely cosmetic. They recolor the van art on
2798 // the customization, driving, and arrival screens -- style for style's sake.
2799 var VAN_STYLES = [
2800 { id: "green", name: "Terrapin Green", color: "#3bf07a" },
2801 { id: "amber", name: "Sunburst Amber", color: "#ffb000" },
2802 { id: "purple", name: "Cosmic Purple", color: "#a855f7" },
2803 { id: "cyan", name: "Ice Blue", color: "#33e0ff" },
2804 { id: "magenta", name: "Sunset Red", color: "#ff6a3d" }, // id kept so saved vans keep their paint
2805 { id: "yellow", name: "Golden Road", color: "#ffe14d" }
2806 ];
2807 function vanStyle(s) { return VAN_STYLES.filter(function (v) { return v.id === (s && s.vanStyle); })[0] || VAN_STYLES[0]; }
2808 function vanColor(s) { return vanStyle(s).color; }
2809 function vanTitle(s) { return (s && s.vanName && s.vanName.trim()) ? s.vanName.trim() : "The Other One"; }
2810 function slotRow(sd, loose) {
2811 var installedId = S.vanmods[sd.slot], right;
2812 if (installedId) {
2813 var g = gearById(installedId);
2814 right = (g.icon || "") + ' ' + esc(g.name) + ' ' + btn("uninstall-" + sd.slot, "Remove", "btn");
2815 } else {
2816 var opts = VAN_PARTS.filter(function (p) { return p.slot === sd.slot && loose[p.id]; });
2817 right = opts.length ? opts.map(function (p) { return btn("install-" + p.id, "Install " + esc(p.name), "btn buy"); }).join(" ")
2818 : '<span class="dim small">empty — buy a part on Shakedown St.</span>';
2819 }
2820 return '<div class="vendor"><span class="amber">' + esc(sd.label) + '</span><span>' + right + '</span></div>';
2821 }
2822 // Creative mode: name the rig and pick a free paint theme -- style for its own sake.
2823 function creativeBox() {
2824 var styleBtns = VAN_STYLES.map(function (v) {
2825 var on = vanStyle(S).id === v.id;
2826 return '<button class="btn stylebtn' + (on ? " on" : "") + '" data-action="vanstyle-' + v.id + '" style="border-color:' + v.color + ';color:' + v.color + '">' +
2827 (on ? "● " : "◯ ") + esc(v.name) + '</button>';
2828 }).join(" ");
2829 return '<div class="box"><div class="cap">Creative mode — make her yours</div>' +
2830 '<label class="crewnamelbl small">Name your van:<br>' +
2831 '<input class="crewname" type="text" maxlength="28" data-van-name value="' + esc(S.vanName || "") + '" placeholder="' + esc(vanTitle(S)) + '"></label>' +
2832 '<p class="small dim" style="margin-top:8px">Paint theme (free — pure style):</p>' +
2833 '<div class="stylegrid">' + styleBtns + '</div></div>';
2834 }
2835 function viewVan() {
2836 var loose = {};
2837 S.stash.forEach(function (id) { if (isVanPart(id)) loose[id] = (loose[id] || 0) + 1; });
2838 var funcSlots = VAN_SLOTS.filter(function (sd) { return !sd.decor; });
2839 var decorSlots = VAN_SLOTS.filter(function (sd) { return sd.decor; });
2840 var rows = funcSlots.map(function (sd) { return slotRow(sd, loose); }).join("");
2841 var decorRows = decorSlots.map(function (sd) { return slotRow(sd, loose); }).join("");
2842 var b = S.buff, perks = [];
2843 if (b.gasCap) perks.push("+" + b.gasCap + " max Gas");
2844 if (b.grubCap) perks.push("+" + b.grubCap + " max Grub");
2845 if (b.wearReduce) perks.push("-" + b.wearReduce + " van wear per leg");
2846 if (b.showVibes) perks.push("+" + b.showVibes + " Vibes per show");
2847 if (b.tapeQuality) perks.push("+" + b.tapeQuality + " tape quality");
2848 if (b.bustGuard) perks.push("stealthier taping");
2849 if (b.roadLuck) perks.push("fewer road surprises");
2850 var backAction = S.vanReturn === "builder" ? "go-builder" : (S.vanReturn === "stash" ? "go-stash" : "go-lot");
2851 return '<span class="scroll-top"></span>' + statusBar() +
2852 '<h3>Customize the van</h3>' +
2853 '<pre class="bears mini vanart" aria-label="the van" style="color:' + vanColor(S) + '">' + esc(SMALL_VAN) + '</pre>' +
2854 '<p class="small cyan" style="text-align:center;margin-top:-2px">🚛 ' + esc(vanTitle(S)) + '</p>' +
2855 '<div class="box"><div class="cap">Installed upgrades</div><p>' + (perks.length ? perks.join(' · ') : '<span class="dim">Bone stock. Install parts from your stash below.</span>') + '</p></div>' +
2856 '<div class="box"><div class="cap">Slots — install gear from your stash</div>' + rows + '</div>' +
2857 creativeBox() +
2858 '<div class="box"><div class="cap">Decor slots — style with a little something</div>' + decorRows + '</div>' +
2859 '<div class="choices" style="max-width:300px">' + btn(backAction, "Done", "choice") + '</div>';
2860 }
2861
2862 // ---- stash / inventory ----
2863 function viewStash() {
2864 var counts = {};
2865 S.stash.forEach(function (id) { counts[id] = (counts[id] || 0) + 1; });
2866 var ids = Object.keys(counts);
2867 var rows = ids.length ? ids.map(function (id) {
2868 var g = gearById(id); if (!g) return "";
2869 var action;
2870 if (isVanPart(id)) action = btn("go-van", "Install on van", "btn");
2871 else if (typeof g.use === "function") action = btn("useitem-" + id, "Use", "btn buy");
2872 else action = '<span class="dim small">works automatically</span>';
2873 return '<div class="vendor"><span>' + (g.icon || "") + ' ' + esc(g.name) + (counts[id] > 1 ? ' x' + counts[id] : "") + '<br><small>' + esc(g.desc) + '</small></span><span>' + action + '</span></div>';
2874 }).join("") : '<p class="dim">Your stash is empty. Grab gear on Shakedown Street.</p>';
2875 var inst = Object.keys(S.vanmods).filter(function (sl) { return S.vanmods[sl]; })
2876 .map(function (sl) { var g = gearById(S.vanmods[sl]); return '<p class="dim small">' + (g.icon || "") + ' ' + esc(g.name) + ' — installed on the van</p>'; }).join("");
2877 return '<span class="scroll-top"></span>' + statusBar() +
2878 '<h3>Your stash</h3>' +
2879 '<div class="box"><div class="cap">Carried gear</div>' + rows + '</div>' +
2880 (inst ? '<div class="box"><div class="cap">On the van</div>' + inst + '</div>' : "") +
2881 '<div class="choices" style="max-width:300px">' +
2882 btn("go-library", '<span class="ic">🎥</span> Tape library' + (S.library.length ? " (" + S.library.length + ")" : ""), "choice") +
2883 btn("go-van", "Tinker with the van", "choice") + btn("go-lot", "Back to the van", "choice") + '</div>';
2884 }
2885
2886 // ---- tape library: a permanent log of every show captured this run ----
2887 var TAPE_SPOT_LABEL = { board: "Soundboard", section: "Taper section", crowd: "Audience" };
2888 function viewLibrary() {
2889 var rows = S.library.length ? S.library.slice().reverse().map(function (t) {
2890 var q = t.quality >= 85 ? "masterpiece" : t.quality >= 60 ? "trade-worthy" : t.quality >= 40 ? "listenable" : "hissy";
2891 return '<div class="vendor"><span>' +
2892 (t.legendary ? '<span class="legend-star">★</span> ' : '') +
2893 esc(t.venue) + '<br><small>' + esc(t.city) + ' · ' + esc(t.date) + '</small></span>' +
2894 '<span class="dim small">' + esc(TAPE_SPOT_LABEL[t.spot] || t.spot) + ' · ' + q + ' (' + t.quality + ')</span></div>';
2895 }).join("") : '<p class="dim">No tapes rolled yet. Get to a show and hit record.</p>';
2896 return '<span class="scroll-top"></span>' + statusBar() +
2897 '<h3>Tape library</h3>' +
2898 '<p class="dim">Every show you\'ve captured this run, in the order you taped them. This is your personal archive — ' +
2899 'selling a tape off the blanket doesn\'t erase it from here.</p>' +
2900 '<div class="box"><div class="cap">' + S.library.length + ' capture' + (S.library.length === 1 ? "" : "s") + '</div>' + rows + '</div>' +
2901 '<div class="choices" style="max-width:300px">' + btn("go-stash", "Back to the stash", "choice") + '</div>';
2902 }
2903
2904 // ---- the Peak ----
2905 function viewPeak() {
2906 var p = S.peakData;
2907 return '<span class="scroll-top"></span>' +
2908 '<div class="card"><div class="frame peakframe"><h2><span class="peaktitle">✨ ' + esc(p.title) + '</span></h2>' +
2909 '<p>' + esc(p.text) + '</p><hr><p class="amber">' + esc(p.line) + '</p>' +
2910 '<div class="choices" style="max-width:280px"><button class="choice" data-action="continue">Come back down ▶</button></div></div></div>';
2911 }
2912
2913 // A gear/van-part shop row, shared by the builder and Shakedown Street. Van parts
2914 // sit in a single slot, so they only ever get a plain Buy; stackable consumables
2915 // also get a Max button to lay in a supply in one tap.
2916 function partInstalled(id) { return Object.keys(S.vanmods).some(function (sl) { return S.vanmods[sl] === id; }); }
2917 function sellPrice(g) { return Math.max(1, Math.floor(g.price / 2)); }
2918 // A gear/van-part shop row. Van parts sit in one slot, so once you own or install
2919 // one the Buy is replaced by an Owned tag; a spare in the stash can be sold back at
2920 // half price. Consumables still stack (Buy/Max) and any spare can be sold back too.
2921 function gearRow(g) {
2922 var afford = S.cash >= g.price;
2923 var isPart = isVanPart(g.id);
2924 var inStash = S.stash.filter(function (x) { return x === g.id; }).length;
2925 var installed = isPart && partInstalled(g.id);
2926 var sp = sellPrice(g);
2927 var right = '<span class="price">' + money(g.price) + '</span> ';
2928 if (isPart) {
2929 if (installed) right += '<span class="cyan small">On the van</span>';
2930 else if (inStash) right += '<span class="dim small">Owned</span> ' + btn("gearsell-" + g.id, "Sell " + money(sp), "btn");
2931 else right += btn("gearbuy-" + g.id, "Buy", "btn buy", !afford);
2932 } else {
2933 right += btn("gearbuy-" + g.id, inStash ? "Buy +1" : "Buy", "btn buy", !afford) + btn("gearmax-" + g.id, "Max", "btn buy", !afford) +
2934 (inStash ? " " + btn("gearsell-" + g.id, "Sell " + money(sp), "btn") : "");
2935 }
2936 return '<div class="vendor"><span>' + (g.icon || "") + ' ' + esc(g.name) + (inStash > 1 ? ' <span class="dim small">x' + inStash + '</span>' : '') + '<br><small>' + esc(g.desc) + '</small></span><span>' + right + '</span></div>';
2937 }
2938 function sellGear(id) {
2939 var g = gearById(id); if (!g) return;
2940 var i = S.stash.indexOf(id); if (i === -1) return;
2941 var sp = sellPrice(g);
2942 S.stash.splice(i, 1); S.cash += sp;
2943 Sound.buy();
2944 S.modNote = "Sold the " + g.name + " for " + money(sp) + ".";
2945 render();
2946 }
2947
2948 function shopRow(id, label, price) {
2949 var afford = S.cash >= price;
2950 return '<div class="vendor"><span>' + esc(label) + '</span>' +
2951 '<span><span class="price">' + money(price) + '</span> ' +
2952 btn("store-" + id, "Buy", "btn buy", !afford) +
2953 btn("storemax-" + id, "Max", "btn buy", !afford) + '</span></div>';
2954 }
2955 var STORE_PRICES = { gas: 4, grub: 5, blanks3: 4, repair: 12 };
2956 function applyStore(s, id) {
2957 if (id === "gas") s.gas = clamp(s.gas + 5, 0, CAP.gas);
2958 if (id === "grub") s.grub = clamp(s.grub + 8, 0, CAP.grub);
2959 if (id === "blanks3") s.blanks += 3;
2960 if (id === "repair") s.van = clamp(s.van + 15, 0, 100);
2961 }
2962 function storeBuy(id) {
2963 var p = STORE_PRICES[id];
2964 if (S.cash < p) { render(); return; }
2965 S.cash -= p;
2966 Sound.buy();
2967 applyStore(S, id);
2968 render();
2969 }
2970 function storeBuyMax(id) {
2971 var p = STORE_PRICES[id];
2972 if (S.cash < p) { render(); return; }
2973 var r = buyMax(p, function (s) { applyStore(s, id); });
2974 Sound.buy();
2975 var labels = { gas: "Gas", grub: "Groceries", blanks3: "blank tapes", repair: "van repairs" };
2976 S.modNote = r.n ? ("Bought " + r.n + "× " + labels[id] + " for " + money(r.n * p) + ".") : "Already topped up on that.";
2977 render();
2978 }
2979
2980 // ---------- buy max (stock up in one tap) ----------
2981 // A purchased unit is "useful" only if it moved some tracked resource; when a
2982 // capped pool is already full (or a one-shot has nothing left to give), the buy
2983 // changes nothing, so buyMax refunds that wasted unit and stops. Bounded at 99
2984 // so a pathological price never spins. applyFn(s) mutates everything but cash.
2985 function nonCashSnap(s) {
2986 return s.gas + "|" + s.grub + "|" + s.van + "|" + s.vibes + "|" + s.tapes + "|" +
2987 s.blanks + "|" + s.cred + "|" + (s.stash ? s.stash.length : 0) + "|" + (s.hasTicket ? 1 : 0);
2988 }
2989 function buyMax(price, applyFn) {
2990 var n = 0, line = "";
2991 while (S.cash >= price && n < 99) {
2992 var before = nonCashSnap(S);
2993 S.cash -= price;
2994 line = applyFn(S);
2995 if (nonCashSnap(S) === before) { S.cash += price; break; } // wasteful unit -> refund, stop
2996 n++;
2997 }
2998 return { n: n, line: line };
2999 }
3000
3001 // A box whose body collapses to just its header. State lives on S.collapsed[key]
3002 // (persisted with the run), toggled by the header button. Until the player has
3003 // touched a given box, it defaults open on desktop but collapsed on a phone-sized
3004 // screen, so a fresh mobile session isn't buried under open panels.
3005 function collapsibleBox(key, cap, body) {
3006 var touched = S.collapsed && Object.prototype.hasOwnProperty.call(S.collapsed, key);
3007 var closedByDefault = HAS_DOM && window.innerWidth <= 600;
3008 var open = touched ? !S.collapsed[key] : !closedByDefault;
3009 return '<div class="box"><button class="cap captoggle" data-action="collapse-' + key + '">' +
3010 (open ? "▾ " : "▸ ") + cap + '</button>' + (open ? body : '') + '</div>';
3011 }
3012
3013 // ---- lot (hub) ----
3014 function viewLot() {
3015 var stop = STOPS[S.stop];
3016 var showBtn;
3017 if (S.missedShow && !S.taped) showBtn = btn("noop", "You missed tonight's show — rolled in too late", "choice", true);
3018 else if (S.taped) showBtn = btn("noop", '<span class="ic">✓</span> Tonight\'s show: done', "choice done", true);
3019 else showBtn = btn("go-tape", '<span class="ic">🎤</span> ' + (stop.finale ? "Play the finale" : "Go to tonight's show") + (stop.legendary ? " ★" : ""), "choice primary");
3020 var ticketBtn = (S.missedShow && !S.taped) ? "" : (S.hasTicket ? '<p class="small cyan">🎟 You\'ve got a ticket for tonight.</p>' : btn("miracle", '<span class="ic">🙏</span> "I need a miracle" (find a ticket)', "choice"));
3021 var sightBtn = (S.legSlack > 0 && !S.taped) ? btn("sightsee", '<span class="ic">📍</span> Go sightseeing — you\'ve got ' + S.legSlack + " spare day" + (S.legSlack > 1 ? "s" : "") + "", "choice") : "";
3022 var campaignNote = (S.campaign && S.campaign.years.length > 1) ?
3023 ' · <span class="cyan">Year ' + (S.campaign.index + 1) + ' of ' + S.campaign.years.length + '</span>' : "";
3024 return '<span class="scroll-top"></span>' + statusBar() +
3025 '<p class="dim">' + esc(stop.date) + ', ' + TOUR_YEAR + campaignNote + ' · Day ' + S.day + ' of the tour · you\'re parked at ' +
3026 '<span class="stop">' + esc(stop.venue) + ', ' + esc(stop.city) + '</span>' + (stop.legendary ? ' <span class="legend-star">★ the big one</span>' : "") + '</p>' +
3027 routeTracker() +
3028 collapsibleBox("pacerations", "Pace & rations for the next leg", paceRow()) +
3029 logBox() +
3030 '<div class="choices">' +
3031 showBtn + ticketBtn + sightBtn +
3032 btn("go-shakedown", '<span class="ic">🎟</span> Walk Shakedown Street (buy / sell / trade)', "choice") +
3033 btn("work", '<span class="ic">💵</span> Work the lot (make some cash)', "choice") +
3034 btn("go-van", '<span class="ic">🔧</span> Tinker with the van', "choice") +
3035 btn("go-stash", '<span class="ic">🎒</span> Check your stash' + (S.stash.length ? " (" + S.stash.length + ")" : ""), "choice") +
3036 btn("rest", '<span class="ic">😴</span> Take a day to rest (+Vibes)', "choice") +
3037 btn("drive", (STOPS[S.stop + 1] ? '<span class="ic">🚛</span> Drive on to ' + esc(STOPS[S.stop + 1].city) + " ▶" : '<span class="ic">🏁</span> That was the last stop'), "choice", !STOPS[S.stop + 1]) +
3038 '</div>';
3039 }
3040
3041 // A quick, funny gut-check before leaving town without ever going to the show.
3042 var SKIP_SHOW_QUIPS = [
3043 "Leaving before the show? Even the roadies will judge you.",
3044 "You'd drive off without hearing a single note? Bold. Reckless. Very un-Dead of you.",
3045 "Somewhere, a taper is quietly weeping. You haven't even plugged in yet.",
3046 "The band hasn't even soundchecked and you're already reaching for the keys.",
3047 "Skipping the show to save fifteen minutes? What are you, a Phish fan?",
3048 "Jerry would not approve. Honestly, nobody in this parking lot would approve.",
3049 "You came all this way. The van has feelings about this. The van is disappointed.",
3050 "Legend says a head who skips the show gets nothing but AM radio for the rest of the tour."
3051 ];
3052 function viewConfirmDrive() {
3053 var stop = STOPS[S.stop], next = STOPS[S.stop + 1];
3054 return '<span class="scroll-top"></span><div class="card"><div class="frame">' +
3055 '<h2>🚨 LEAVING ALREADY?</h2>' +
3056 '<p>' + esc(pick(SKIP_SHOW_QUIPS)) + '</p>' +
3057 '<p>You haven\'t caught tonight\'s show at <span class="stop">' + esc(stop.venue) + '</span> yet.</p>' +
3058 '<div class="choices" style="max-width:340px">' +
3059 btn("go-tape", '<span class="ic">🎤</span> Actually, let\'s catch the show', "choice primary") +
3060 btn("drive-confirm", '<span class="ic">🚛</span> Drive off anyway' + (next ? " to " + esc(next.city) : ""), "choice") +
3061 '</div></div></div>';
3062 }
3063
3064 function paceRow() {
3065 function seg(kind, key, val) {
3066 var cur = S[kind] === val;
3067 return '<button class="btn ' + (cur ? "" : "ghost") + '" data-action="set-' + kind + '-' + val + '">' + (cur ? "● " : "◯ ") + key + '</button>';
3068 }
3069 // Live ETA: at the chosen pace, will you make the next show or roll in late?
3070 var need = legNeeds(S), eta = "";
3071 if (need) {
3072 var d = legDays(S, need.miles);
3073 var late = d > need.gap;
3074 eta = '<p class="small ' + (late ? "danger" : "dim") + '">Next show is <span class="amber">' + need.gap + ' day' + (need.gap > 1 ? "s" : "") + '</span> out (' + need.miles + ' mi). ' +
3075 'At this pace: <span class="' + (late ? "danger" : "cyan") + '">' + d + ' day' + (d > 1 ? "s" : "") + '</span> — ' +
3076 (late ? "you\'ll MISS it. Floor it or you\'re late." : (need.gap - d > 0 ? "you\'ll arrive with " + (need.gap - d) + " to spare." : "you\'ll just make it.")) + '</p>';
3077 }
3078 return '<p>Pace: ' + seg("pace", "Scenic", "scenic") + seg("pace", "Mellow", "mellow") + seg("pace", "Cruisin'", "cruisin") + seg("pace", "Full Tilt", "balls") + '</p>' +
3079 '<p>Rations: ' + seg("rations", "Feast", "feast") + seg("rations", "Generous", "generous") + seg("rations", "Sensible", "sensible") + seg("rations", "Sparse", "sparse") + '</p>' + eta;
3080 }
3081
3082 // ---- shakedown ----
3083 // Shakedown vendors grouped so the lot is browsable instead of one long list.
3084 var SHAKE_CATS = [
3085 { key: "shake-food", cap: "🍝 Food & Drink", ids: ["cheese", "burrito", "tamales", "veggiedogs", "oranges", "cookies", "coffee", "tea", "kombucha", "lemonade"] },
3086 { key: "shake-style", cap: "🌌 Style, Charms & Vibes", ids: ["tiedye", "poncho", "pin", "bracelet", "crystal", "incense", "djembe", "nitrous"] },
3087 { key: "shake-road", cap: "🎥 Tapes, Gas & the Road", ids: ["blanks", "masters", "gas", "repair", "ticket"] }
3088 ];
3089 function shakeItemCard(it) {
3090 var price = lotPrice(S, it.price, it.id);
3091 var afford = S.cash >= price;
3092 var mm = marketMult(S, it.id);
3093 var arrow = mm > 1.02 ? ' <span class="danger" title="pricey here">▲</span>' : (mm < 0.98 ? ' <span class="cyan" title="cheap here">▼</span>' : '');
3094 var deal = crewHas(S, "deb") ? ' <span class="dim small">Deb</span>' : '';
3095 return '<div class="shakecard' + (afford ? '' : ' cantafford') + '">' +
3096 '<div class="who">' + esc(it.who) + '</div>' +
3097 '<div class="iname">' + esc(it.name) + '</div>' +
3098 '<div class="idesc">' + esc(it.desc) + '</div>' +
3099 '<div class="ibuy"><span class="price">' + money(price) + '</span>' + arrow + deal + ' ' +
3100 btn("buy-" + it.id, "Buy", "btn buy", !afford) + btn("buymax-" + it.id, "Max", "btn buy", !afford) + '</div></div>';
3101 }
3102 function viewShakedown() {
3103 var stop = STOPS[S.stop];
3104 var byId = {}; LOT_ITEMS.forEach(function (it) { byId[it.id] = it; });
3105 var used = {};
3106 var cats = SHAKE_CATS.map(function (c) {
3107 var cards = c.ids.filter(function (id) { return byId[id]; }).map(function (id) { used[id] = 1; return shakeItemCard(byId[id]); }).join("");
3108 return collapsibleBox(c.key, c.cap, '<div class="shakegrid">' + cards + '</div>');
3109 }).join("");
3110 var leftover = LOT_ITEMS.filter(function (it) { return !used[it.id]; });
3111 if (leftover.length) cats += collapsibleBox("shake-leftover", "Everything else", '<div class="shakegrid">' + leftover.map(shakeItemCard).join("") + '</div>');
3112 var mood = S.market ? '<p class="bar ' + esc(S.market.moodCls) + '">💰 ' + esc(S.market.moodLabel) + '</p>' : '';
3113 var gearRows = VAN_PARTS.concat(CONSUMABLES).map(gearRow).join("");
3114 return '<span class="scroll-top"></span><h3>Shakedown Street — ' + esc(stop.city) + ' lot</h3>' +
3115 '<pre class="bears mini" aria-label="dancing bears">' + esc(BEARS_MINI) + '</pre>' +
3116 statusBar() + mood +
3117 cats +
3118 collapsibleBox("shake-gear", "🔧 Gear & van parts (into your stash)", gearRows) +
3119 '<div class="box"><div class="cap">🎧 Off your own blanket</div>' +
3120 (S.collector ? '<p class="small">Tonight\'s buyer <span class="' + esc(S.collector.cls) + '">' + esc(S.collector.want) + '</span>.</p>' : '') +
3121 '<div class="vendor"><span>Sell a tape to the collector' + (S.tapes < 1 ? ' <span class="dim small">(case empty)</span>' : '') + '</span>' + btn("sell-tape", "Haggle", "btn buy", S.tapes < 1) + '</div>' +
3122 '<div class="vendor"><span>Dub a copy off the blanket<br><small>spend a blank, keep your master</small></span>' + btn("dub-tape", "Dub & sell", "btn buy", S.tapes < 1 || S.blanks < 1) + '</div></div>' +
3123 '<div class="choices" style="max-width:300px">' + btn("go-van", "Install parts on the van 🔧", "choice") + btn("go-lot", "Back to the van", "choice") + '</div>';
3124 }
3125 var BEARS_MINI = String.raw` ()() ()() ()() ()() ()()
3126(o.o) (^.^) (o.o) (-.-) (^o^)
3127/( )\ \( )/ /( )\ \( )/ /( )\ `;
3128
3129 // ---- the blanket haggle ----
3130 function viewHaggle() {
3131 var h = S.haggle;
3132 if (!h) return '<span class="scroll-top"></span>' + statusBar() + '<h3>Nothing on the blanket</h3>' +
3133 '<div class="choices" style="max-width:280px">' + btn("go-shakedown", "Back to Shakedown", "choice") + '</div>';
3134 var c = S.collector || { want: "wants a tape", cls: "dim" };
3135 var canCounter = h.counters < 2;
3136 var match = c.spot && S.lastTape && S.lastTape.spot === c.spot;
3137 var lines = ['<span class="scroll-top"></span>' + statusBar()];
3138 lines.push('<div class="card"><div class="frame"><h2>🎧 ON THE BLANKET</h2>');
3139 lines.push('<p>The buyer working the lot <span class="' + esc(c.cls) + '">' + esc(c.want) + '</span>.</p>');
3140 if (S.lastTape) lines.push('<p class="dim small">Your latest catch: a ' + esc(S.lastTape.spot) + ' tape of ' + esc(S.lastTape.venue) + '.' + (match ? ' <span class="cyan">Exactly what they\'re after.</span>' : '') + '</p>');
3141 if (h.copy) lines.push('<p class="dim small">You\'re dubbing a copy — a blank goes only if the deal closes; your master stays in the bag.</p>');
3142 lines.push('<hr><p>They open with <span class="amber" style="font-size:26px">' + money(h.offer) + '</span>' + (h.counters ? ' <span class="cyan small">(after ' + h.counters + ' round' + (h.counters > 1 ? "s" : "") + ')</span>' : '') + '.</p>');
3143 lines.push('<div class="choices">');
3144 lines.push(btn("haggle-accept", '🤝 Take ' + money(h.offer), "choice"));
3145 lines.push(btn("haggle-counter", '💰 Hold out for more' + (canCounter ? '' : ' (they\'re done haggling)'), "choice", !canCounter));
3146 lines.push(btn("haggle-hold", (h.copy ? '📦 Keep the blank — walk' : '📦 Keep the tape — walk'), "choice"));
3147 lines.push('</div></div></div>');
3148 return lines.join("");
3149 }
3150
3151 // ---- taping ----
3152 function viewTape() {
3153 var stop = STOPS[S.stop];
3154 var inside = S.hasTicket;
3155 var lines = ['<h3>Tape the show — ' + esc(stop.venue) + '</h3>'];
3156 if (stop.legendary) lines.push('<p class="legend-star">This is the one, ' + esc(stop.date) + '. Get it clean and you\'ll own a piece of history.</p>');
3157 lines.push('<p class="small">Blank tapes: <span class="amber">' + S.blanks + '</span> · ' + (inside ? '<span class="cyan">you\'re inside tonight</span>' : '<span class="dim">no ticket — you can only record from the lot</span>') + '</p>');
3158 if (S.blanks < 1) {
3159 lines.push('<p class="danger">You\'re out of blank tapes. Grab a 3-pack on Shakedown Street.</p>');
3160 lines.push('<div class="choices" style="max-width:280px">' + btn("go-lot", "Back", "choice") + '</div>');
3161 return '<span class="scroll-top"></span>' + lines.join("");
3162 }
3163 // WHERE the room lands, and how fried you are, both shade the tape.
3164 var rb = roomBonus(stop);
3165 var roomNote = rb > 0 ? '<span class="cyan">a tight, crisp room</span>' : (rb < 0 ? '<span class="amber">a big, boomy room</span>' : '<span class="dim">a fair room</span>');
3166 var vibeNote = S.vibes < 25 ? ' · <span class="danger">you\'re fried — shaky hands</span>' : (S.vibes >= 80 ? ' · <span class="cyan">blissed — steady hands</span>' : '');
3167 lines.push('<p class="small dim">Tonight: ' + roomNote + vibeNote + '</p>');
3168 // WHEN -- pick the set. Encore is the high-risk, high-reward magic hour.
3169 function setSeg(val, label) {
3170 var cur = (S.tapeSet || "second") === val;
3171 return '<button class="btn ' + (cur ? "" : "ghost") + '" data-action="set-tapeset-' + val + '">' + (cur ? "● " : "◯ ") + label + '</button>';
3172 }
3173 lines.push('<div class="box"><div class="cap">When do you roll tape?</div>' +
3174 '<p>' + setSeg("first", "First set") + setSeg("second", "Second set") + setSeg("encore", "Encore") + '</p>' +
3175 '<p class="small dim">' + (S.tapeSet === "first" ? "First set: easy to catch, but the band's still warming up (lower ceiling)."
3176 : S.tapeSet === "encore" ? "Encore: tighter windows, faster needle -- but the best tape on the tour if you nail it."
3177 : "Second set: the meat of the show. Balanced.") + '</p></div>');
3178 lines.push('<div class="choices">');
3179 lines.push(btn("tape-board", 'Splice into the SOUNDBOARD <span class="dim">(best quality, high bust risk)</span>', "choice", !inside));
3180 lines.push(btn("tape-section", 'Set up in the TAPER SECTION <span class="dim">(balanced)</span>', "choice", !inside));
3181 lines.push(btn("tape-crowd", 'Record from the CROWD / lot <span class="dim">(safe, rougher sound)</span>', "choice"));
3182 lines.push(btn("attend", 'Leave the deck behind and just dance', "choice"));
3183 lines.push(btn("go-lot", "Back", "choice"));
3184 lines.push('</div>');
3185 return '<span class="scroll-top"></span>' + lines.join("");
3186 }
3187
3188 // ---- taping mini-game view ----
3189 function viewMini() {
3190 var m = S.mini; var st = m.stages[m.idx]; var stop = STOPS[S.stop];
3191 var danger = !!st.danger;
3192 var zoneLeft = st.center - st.half, zoneW = st.half * 2;
3193 var RATING_MARK = { perfect: "✦", great: "●", good: "●", miss: "○" };
3194 var pips = m.stages.map(function (s, i) {
3195 if (i < m.idx) { var rt = m.results[i].rating; return '<span class="pip-' + rt + '">' + (RATING_MARK[rt] || "●") + '</span>'; }
3196 if (i === m.idx) return '<span class="todo">◎</span>';
3197 return '<span class="todo">·</span>';
3198 }).join(" ");
3199 var RATING_LABEL = { perfect: "PERFECT!", great: "GREAT", good: "in the zone", miss: "missed it" };
3200 var feedback = m.lastRating
3201 ? '<span class="pip-' + m.lastRating + '">' + esc(RATING_LABEL[m.lastRating]) + '</span>'
3202 : '<span class="dim">get ready…</span>';
3203 var comboBadge = m.combo >= 2 ? ' <span class="combo">🔥 COMBO ×' + m.combo + '</span>' : '';
3204 return '<span class="scroll-top"></span>' + statusBar() +
3205 '<div class="mini"><h3>Recording — ' + esc(stop.venue) + '</h3>' +
3206 (stop.legendary ? '<p class="legend-star">★ This is the one, ' + esc(stop.date) + '. Get it clean.</p>' : '') +
3207 '<p class="stagehdr">Stage ' + (m.idx + 1) + ' of ' + m.stages.length + ' — ' + esc(st.label) + '</p>' +
3208 '<p class="small dim">' + (danger
3209 ? "Lock when the needle sits in the GREEN — that's when the coast is clear. Miss it and you're busted."
3210 : "Lock the needle in the sweet spot. Dead-center is a PERFECT — chain them for a combo bonus.") + '</p>' +
3211 '<div class="meter' + (danger ? " dangerbg" : "") + '">' +
3212 '<div class="meterlabel">' + (danger ? "SECURITY SWEEP" : "LEVELS") + '</div>' +
3213 '<div class="zone' + (danger ? "" : " sweet") + '" style="left:' + zoneLeft + '%;width:' + zoneW + '%"></div>' +
3214 (danger ? "" : '<div class="bullseye" style="left:' + st.center + '%"></div>') +
3215 '<div class="needle" id="needle" style="left:' + m.pos + '%"></div>' +
3216 '</div>' +
3217 '<p class="pips">' + pips + '<span class="minifb">' + feedback + comboBadge + '</span></p>' +
3218 '<div class="choices" style="max-width:300px">' +
3219 '<button class="choice" data-action="lock-mini">◉ LOCK IT IN</button>' +
3220 btn("go-lot", "Bail on the recording", "choice") + '</div></div>';
3221 }
3222
3223 // ---- event ----
3224 function viewEvent() {
3225 var ev = S.currentEvent;
3226 var ch = ev.choices.map(function (c, i) {
3227 var costTxt = "";
3228 if (c.cost) { costTxt = ' <span class="cost">' + Object.keys(c.cost).map(function (k) { return "-" + c.cost[k] + " " + k; }).join(", ") + '</span>'; }
3229 return btn("ev-" + i, esc(resolveCrew(S, c.label)) + costTxt, "choice");
3230 }).join("");
3231 return '<span class="scroll-top"></span>' + statusBar() +
3232 '<div class="card"><div class="frame"><h2>⚠ ' + esc(resolveCrew(S, ev.title)) + '</h2>' +
3233 '<p>' + esc(resolveCrew(S, ev.text)) + '</p><hr><div class="choices">' + ch + '</div></div></div>';
3234 }
3235 function chooseEvent(i) {
3236 var ev = S.currentEvent;
3237 var c = ev.choices[i];
3238 var r = c.apply(S);
3239 var line = resolveCrew(S, r.line), title = resolveCrew(S, ev.title);
3240 addLog(S, title + ": " + line, r.good ? "good" : "bad");
3241 Sound[r.good ? "good" : "bad"]();
3242 showResult(title, [line], null); // next handled by resumeLeg via mode
3243 }
3244
3245 // ---- result card ----
3246 function viewResultCard() {
3247 var r = S.result;
3248 var lines = r.lines.map(function (l) { return '<p>' + esc(l) + '</p>'; }).join("");
3249 return '<span class="scroll-top"></span>' + statusBar() +
3250 '<div class="card"><div class="frame"><h2>' + esc(r.title) + '</h2>' + lines +
3251 '<div class="choices" style="max-width:260px"><button class="choice" data-action="continue">Continue ▶</button></div></div></div>';
3252 }
3253
3254 // ---- arrival ----
3255 function viewArrive() {
3256 var stop = STOPS[S.stop];
3257 return '<span class="scroll-top"></span>' + statusBar() +
3258 '<div class="card"><div class="frame"><h2>🚛 ' + esc(stop.city) + '</h2>' +
3259 '<p>You roll into the lot at <span class="cyan">' + esc(stop.venue) + '</span> on ' + esc(stop.date) + ', ' + TOUR_YEAR + '.</p>' +
3260 (stop.legendary ? '<p class="legend-star">★ Word in the lot is tonight is going to be special. Get your rig ready.</p>' : '') +
3261 (stop.finale ? '<p class="legend-star">The last night of the run. Make it count.</p>' : '') +
3262 (S.crewPool ? '<hr><p class="amber">🤝 ' + esc(S.crewPool.line) + '</p>' : '') +
3263 (S.crewFind ? '<p class="amber">🎒 ' + esc(S.crewFind.line) + '</p>' : '') +
3264 (S.crewQuit ? '<hr><p class="danger">💔 ' + esc(S.crewQuit.line) + '</p>' : '') +
3265 '<div class="choices" style="max-width:260px"><button class="choice" data-action="go-lot">Hit the lot ▶</button></div></div></div>';
3266 }
3267
3268 // ---- sightseeing: pick a detour ----
3269 function viewSightsee() {
3270 var opts = S.sightOptions || [];
3271 var cards = opts.map(function (o, i) {
3272 return '<button class="col rolecard" data-action="sight-' + i + '">' +
3273 '<span class="cap">' + esc(o.name) + '</span>' +
3274 '<span class="rc-blurb">Go ' + esc(o.flavor.verb) + '.</span>' +
3275 '<span class="rc-go">Take the crew ▶</span></button>';
3276 }).join("");
3277 return '<span class="scroll-top"></span><h3>A spare day on the road</h3>' +
3278 '<p class="dim">You\'ve got ' + S.legSlack + ' spare day' + (S.legSlack > 1 ? "s" : "") + ' before the next show. Pick a detour.</p>' +
3279 '<div class="row">' + cards + '</div>' +
3280 '<div class="choices" style="max-width:280px">' + btn("go-lot", "Never mind, stay put", "choice") + '</div>';
3281 }
3282
3283 // ---- driving intermission ----
3284 function viewDriving() {
3285 var d = S.drive;
3286 return '<span class="scroll-top"></span>' + statusBar() +
3287 '<h3>🚛 On the road to ' + esc(d.toCity) + '</h3>' +
3288 '<div class="drivescene">' +
3289 '<pre class="scenery">' + esc(d.scenery || SCENERY) + '</pre>' +
3290 '<div class="roadline">' + esc(ROAD) + '</div>' +
3291 '<pre class="exhaust">o O . o</pre>' +
3292 '<pre class="vanlayer" style="color:' + vanColor(S) + ';text-shadow:0 0 6px ' + vanColor(S) + '">' + esc(SMALL_VAN) + '</pre>' +
3293 '<div class="vanname">' + esc(vanTitle(S)) + '</div>' +
3294 '</div>' +
3295 '<div class="box"><div class="cap">' + esc(d.fromCity) + ' → ' + esc(d.toCity) + '</div>' +
3296 '<div class="drivebar"><span class="fill" style="animation-duration:' + d.ms + 'ms"></span></div>' +
3297 '<p id="drive-odo" class="amber">0 / ' + d.miles + ' mi</p>' +
3298 '<p id="drive-cap" class="dim">' + esc(d.caps[0]) + '</p></div>' +
3299 '<div class="choices" style="max-width:240px"><button class="choice" data-action="skip-drive">Skip ahead ▶</button></div>';
3300 }
3301
3302 // ---- gameover ----
3303 function viewGameover() {
3304 var cause = S.cause;
3305 var head, body;
3306 if (cause === "dosed") {
3307 head = "YOU HAVE BEEN DOSED.";
3308 body = "You accepted a mystery snack. You wandered off following a setlist only you could hear. Three days later you turned up in a drum circle in a different state, spiritually enriched and completely broke.";
3309 } else if (cause === "alone") {
3310 head = "THE VAN IS EMPTY.";
3311 body = "One by one the crew drifted into the scene until it was just you, the tape deck, and a lot of empty seats. The tour goes on without you.";
3312 } else {
3313 head = "THE END OF THE ROAD.";
3314 body = cause || "The road took its toll.";
3315 }
3316 return '<span class="scroll-top"></span><div class="center">' +
3317 '<div><pre class="bears big" aria-label="dancing bears">' + gameoverBearsColored() + '</pre></div>' +
3318 '<div class="melt">' + esc(head) + '</div>' +
3319 '<p>' + esc(body) + '</p>' +
3320 '<p class="dim">Tapes: ' + S.tapes + ' · Legendary: ' + S.legendaryTapes + ' · Shows: ' + S.showsAttended + ' · Miles: ' + S.miles + ' · Day ' + S.day + '</p>' +
3321 '<p class="amber">Final score: ' + S.score + '</p>' +
3322 '<p class="amber">The scene remembers you fondly. Mostly.</p>' +
3323 emailBuddyLink() +
3324 newAchievementsBlock() +
3325 '<div class="choices" style="max-width:300px;margin:0 auto">' +
3326 btn("go-tours", "Get back on the bus", "choice") + btn("go-hall", "Hall of the Fallen", "choice") + btn("go-ach", "Badges", "choice") +
3327 '</div></div>';
3328 }
3329 function gameoverBearsColored() {
3330 return esc(GAMEOVER_BEARS);
3331 }
3332
3333 // ---- win ----
3334 function viewWin() {
3335 var pe = S.pendingEnd;
3336 var finale = pe && pe.finale ? '<div class="box"><div class="cap">The last night</div>' + pe.finale.lines.map(function (l) { return '<p>' + esc(l) + '</p>'; }).join("") + '</div>' : "";
3337 var years = (S.campaign && S.campaign.years) || [TOUR_YEAR];
3338 var span = years.length > 1 ? '<p class="amber">A ' + years.length + '-year run: ' + years.join(" → ") + '.</p>' : "";
3339 return '<span class="scroll-top"></span><div class="center">' +
3340 '<div class="win-title">★ YOU FINISHED THE TOUR ★</div></div>' +
3341 finale + span +
3342 '<p>From ' + esc(STOPS[0].city.split(",")[0]) + ' to ' + esc(STOPS[STOPS.length - 1].city.split(",")[0]) +
3343 ', you followed the whole <span class="amber">' + esc(TOUR_LABEL) + '</span> run and lived to tell it. ' +
3344 'Your tapes will circulate for decades. Somewhere, a kid in 2017 is going to hear one and lose their mind.</p>' +
3345 '<div class="box"><div class="cap">The long strange trip, tallied</div>' +
3346 '<p>Tapes in the case: <span class="amber">' + S.tapes + '</span>' + (S.legendaryTapes ? ' · Legendary captures: <span class="legend-star">' + S.legendaryTapes + ' ★</span>' : "") + '</p>' +
3347 '<p>Shows caught: <span class="amber">' + S.showsAttended + '</span> · Miles logged: <span class="amber">' + S.miles + '</span> · Days on tour: <span class="amber">' + S.day + '</span></p>' +
3348 '<p>Lot Cred: <span class="amber">' + S.cred + '</span> · Vibes: <span class="amber">' + vibesLabel(S.vibes) + '</span> · Cash: <span class="amber">' + money(S.cash) + '</span></p>' +
3349 '<p class="win-title" style="font-size:26px">Final score: ' + S.score + '</p></div>' +
3350 emailBuddyLink() +
3351 newAchievementsBlock() +
3352 '<div class="choices" style="max-width:320px">' +
3353 btn("go-tours", "Do it all again", "choice") + btn("go-hall", "Hall of the Fallen", "choice") + btn("go-ach", "Badges", "choice") +
3354 '</div>';
3355 }
3356
3357 // ---- hall ----
3358 // One year's high-score table (runs already filtered to that year), ranked by
3359 // total points. Reused per season so adding a year is a data change, not new code.
3360 function hallTable(runs) {
3361 var sorted = runs.slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); }).slice(0, 20);
3362 return '<div class="hallwrap"><table class="hall"><tr><th>#</th><th>Trip</th><th>Van-mates</th><th>Fate</th><th>Tapes</th><th>Shows</th><th>Miles</th><th class="sc">Total pts</th></tr>' +
3363 sorted.map(function (r, i) {
3364 return '<tr><td class="rank">' + (i + 1) + '</td><td>' + esc(r.name || "A head") + '</td>' +
3365 '<td class="dim small">' + esc(r.crew || "solo") + '</td>' +
3366 '<td class="outcome-' + (r.outcome === "won" ? "win" : "lost") + '">' + esc(r.cause || r.outcome) + '</td>' +
3367 '<td>' + (r.tapes || 0) + (r.legendary ? ' <span class="legend-star">' + r.legendary + '★</span>' : "") + '</td>' +
3368 '<td>' + (r.shows || 0) + '</td><td>' + (r.miles || 0) + '</td>' +
3369 '<td class="sc">' + (r.score || 0) + '</td></tr>';
3370 }).join("") + '</table></div>';
3371 }
3372 function viewHall() {
3373 var head = '<span class="scroll-top"></span><h3>Hall of the Fallen & the Triumphant</h3>' +
3374 '<p class="dim">Every soul who ever pointed a van at the horizon — road legends, cautionary tales, and at least one who\'s still convinced they\'re a houseplant.</p>';
3375 var back = '<div class="choices" style="max-width:280px">' + btn("go-title", "Back", "choice") + '</div>';
3376 if (!HALL) return head + '<p class="dim">Loading the ledger...</p>' + back;
3377 if (!HALL.length) return head + '<p class="dim">Nobody\'s hit the road yet. Be the first legend (or cautionary tale).</p>' + back;
3378 // Bucket every run by its tour year (older records with no year belong to the
3379 // original 1977 run), then render newest year first -- one high-score list each.
3380 var byYear = {};
3381 HALL.forEach(function (r) {
3382 // Legacy records predate the `year` field entirely -- they always belong to
3383 // the original 1977 tour, regardless of whichever tour happens to be loaded
3384 // right now (TOUR_YEAR changes every time the player picks a different tour).
3385 var y = (typeof r.year === "number" && r.year) ? r.year : DEFAULT_TOUR_YEAR;
3386 (byYear[y] = byYear[y] || []).push(r);
3387 });
3388 var years = Object.keys(byYear).map(Number);
3389 // Show a section for any configured season too, even before it has runs.
3390 Object.keys(HALL_SEASONS).map(Number).forEach(function (y) { if (years.indexOf(y) === -1) years.push(y); });
3391 years.sort(function (a, b) { return b - a; });
3392 var sections = years.map(function (y) {
3393 var runs = byYear[y] || [];
3394 var cap = '<div class="cap">' + esc(seasonLabel(y)) + ' — ' + y + ' · ' + runs.length + ' run' + (runs.length === 1 ? "" : "s") + '</div>';
3395 var body = runs.length ? hallTable(runs) : '<p class="dim small">No runs logged for this tour yet. Be the first.</p>';
3396 return '<div class="box">' + cap + body + '</div>';
3397 }).join("");
3398 return head + sections + back;
3399 }
3400
3401 // ---- achievements / badges ----
3402 // A block celebrating milestones just unlocked this run, for the end screens.
3403 function newAchievementsBlock() {
3404 var fresh = S && S.newAchievements;
3405 if (!fresh || !fresh.length) return "";
3406 var rows = fresh.map(function (a) {
3407 return '<p class="amber">' + (a.icon || "★") + ' <b>' + esc(a.name) + '</b> — <span class="dim">' + esc(a.desc) + '</span></p>';
3408 }).join("");
3409 return '<div class="box"><div class="cap">🏆 Badge' + (fresh.length > 1 ? "s" : "") + ' unlocked this run</div>' + rows + '</div>';
3410 }
3411 function viewAchievements() {
3412 var got = unlockedCount(), total = ACHIEVEMENTS.length;
3413 var rows = ACHIEVEMENTS.map(function (a) {
3414 var on = isUnlocked(a.id);
3415 return '<div class="vendor"><span>' + (on ? (a.icon || "★") : "🔒") + ' <span class="' + (on ? "amber" : "dim") + '">' + esc(a.name) + '</span>' +
3416 '<br><small class="dim">' + (on ? esc(a.desc) : "Still locked — keep touring.") + '</small></span>' +
3417 '<span class="' + (on ? "cyan" : "dim") + ' small">' + (on ? "unlocked" : "—") + '</span></div>';
3418 }).join("");
3419 return '<span class="scroll-top"></span><h3>Badges & Milestones</h3>' +
3420 '<p class="dim">Hard-won over a lifetime of questionable decisions and excellent shows — <span class="amber">' + got + '</span> of ' + total + ' unlocked. No refunds on the ones you earned the hard way.</p>' +
3421 '<div class="box"><div class="cap">Your badges</div>' + rows + '</div>' +
3422 '<div class="choices" style="max-width:280px">' + btn("go-title", "Back", "choice") + '</div>';
3423 }
3424
3425 // button helper
3426 function btn(action, label, cls, disabled) {
3427 return '<button class="' + (cls || "btn") + '" data-action="' + action + '"' + (disabled ? " disabled" : "") + '>' + label + '</button>';
3428 }
3429
3430 // ---------- event delegation ----------
3431 function onClick(e) {
3432 var t = e.target.closest("[data-action]");
3433 if (!t) return;
3434 // first gesture unlocks audio; a soft tick gives every button tactile feedback
3435 Sound.init(); Sound.resume(); Sound.tap();
3436 var a = t.getAttribute("data-action");
3437 handle(a);
3438 }
3439
3440 function handle(a) {
3441 // navigation
3442 if (a === "go-title") { S = S || {}; S.view = "title"; render(); return; }
3443 if (a === "go-about") { S = S || {}; S.view = "about"; fetchBlogPosts(); render(); return; }
3444 if (a === "start-role") { S = S || {}; S.view = "role"; render(); return; }
3445 if (a === "go-tours") { S = S || {}; S.tourPreview = TOUR_YEAR; S.view = "tours"; render(); return; }
3446 if (a.indexOf("tourprev-") === 0) { S = S || {}; S.tourPreview = parseInt(a.slice(9), 10); S.view = "tours"; render(); return; }
3447 if (a.indexOf("pick-tour-") === 0) { setTour(parseInt(a.slice(10), 10)); S = S || {}; PENDING_CAMPAIGN_LEN = 1; S.view = "campaign"; render(); return; }
3448 if (a.indexOf("camplen-") === 0) { PENDING_CAMPAIGN_LEN = parseInt(a.slice(8), 10); S = S || {}; S.view = "role"; render(); return; }
3449 if (a === "resume") { resumeSavedGame(); return; }
3450 if (a === "quick-start") { quickStart(); return; }
3451 if (a === "go-hall") { if (!S) S = {}; S.view = "hall"; render(); loadHall(render); return; }
3452 if (a === "go-ach") { if (!S) S = {}; S.view = "ach"; render(); return; }
3453 if (a === "go-lot") { S.view = "lot"; render(); return; }
3454 if (a === "go-shakedown") { S.view = "shakedown"; render(); return; }
3455 if (a === "go-tape") { S.view = "tape"; render(); return; }
3456 if (a === "go-crew") { S.view = "crew"; render(); return; }
3457 if (a === "go-builder") { S.view = "builder"; render(); return; }
3458 if (a === "go-van") { S.vanReturn = (S.view === "builder") ? "builder" : (S.view === "stash" ? "stash" : "lot"); S.view = "van"; render(); return; }
3459 if (a === "go-stash") { S.view = "stash"; render(); return; }
3460 if (a === "go-library") { S.view = "library"; render(); return; }
3461 if (a === "continue") { continueResult(); return; }
3462 if (a === "noop") { return; }
3463
3464 // role pick -> van-mate selection -> character builder
3465 if (a.indexOf("pick-") === 0) {
3466 var role = ROLES.filter(function (r) { return r.id === a.slice(5); })[0];
3467 newGame(role); S.view = "crew"; render(); return;
3468 }
3469 if (a === "rand-role") { newGame(pick(ROLES)); S.view = "crew"; render(); return; }
3470 if (a.indexOf("van-") === 0) { toggleVanmate(a.slice(4)); return; }
3471 if (a === "rand-crew") { randomizeCrew(); return; }
3472 if (a === "crew-done") { rebuildCrew(S); S.view = "builder"; render(); return; }
3473 if (a === "start-trip") { finalizeBuild(); return; }
3474
3475 // builder: mods + gear
3476 if (a.indexOf("mod-") === 0) { toggleMod(a.slice(4)); return; }
3477 if (a === "rand-mods") { randomizeMods(); return; }
3478 if (a.indexOf("gearbuy-") === 0) { buyGear(a.slice(8)); return; }
3479 if (a.indexOf("gearmax-") === 0) { buyGearMax(a.slice(8)); return; }
3480 if (a.indexOf("gearsell-") === 0) { sellGear(a.slice(9)); return; }
3481
3482 // van customization + stash
3483 if (a.indexOf("vanstyle-") === 0) { S.vanStyle = a.slice(9); render(); return; }
3484 if (a.indexOf("install-") === 0) { installPart(a.slice(8)); return; }
3485 if (a.indexOf("uninstall-") === 0) { uninstallSlot(a.slice(10)); return; }
3486 if (a.indexOf("useitem-") === 0) { useConsumable(a.slice(8)); return; }
3487
3488 // store buys
3489 if (a.indexOf("storemax-") === 0) { storeBuyMax(a.slice(9)); return; }
3490 if (a.indexOf("store-") === 0) { storeBuy(a.slice(6)); return; }
3491
3492 // pace/rations
3493 if (a.indexOf("set-pace-") === 0) { S.pace = a.slice(9); render(); return; }
3494 if (a.indexOf("set-rations-") === 0) { S.rations = a.slice(12); render(); return; }
3495 if (a.indexOf("set-tapeset-") === 0) { S.tapeSet = a.slice(12); render(); return; }
3496
3497 // driving intermission
3498 if (a === "skip-drive") { finishDrive(); return; }
3499
3500 // lot actions
3501 if (a === "drive") {
3502 if (!S.taped && !S.missedShow) { S.view = "confirm-drive"; render(); return; }
3503 driveOn(); return;
3504 }
3505 if (a === "drive-confirm") { driveOn(); return; }
3506 if (a === "rest") { rest(); return; }
3507 if (a === "sightsee") { doSightsee(); return; }
3508 if (a.indexOf("sight-") === 0) { chooseSightsee(parseInt(a.slice(6), 10)); return; }
3509 if (a.indexOf("collapse-") === 0) { var ck = a.slice(9); S.collapsed = S.collapsed || {}; S.collapsed[ck] = !S.collapsed[ck]; render(); return; }
3510 if (a === "work") { S.view = "work"; render(); return; }
3511 if (a === "miracle") { tryMiracle(); return; }
3512
3513 // work hustles
3514 if (a.indexOf("hustle-") === 0) { doHustle(a.slice(7)); return; }
3515
3516 // shakedown
3517 if (a.indexOf("buymax-") === 0) { buyItemMax(a.slice(7)); return; }
3518 if (a.indexOf("buy-") === 0) { buyItem(a.slice(4)); return; }
3519 if (a === "sell-tape") { startSellTape(false); return; }
3520 if (a === "dub-tape") { startSellTape(true); return; }
3521 if (a === "haggle-accept") { if (S.haggle) haggleClose(S.haggle); return; }
3522 if (a === "haggle-counter") { haggleCounter(); return; }
3523 if (a === "haggle-hold") { S.haggle = null; S.view = "shakedown"; render(); return; }
3524
3525 // taping
3526 if (a === "tape-board") { startTaping("board"); return; }
3527 if (a === "tape-section") { startTaping("section"); return; }
3528 if (a === "tape-crowd") { startTaping("crowd"); return; }
3529 if (a === "lock-mini") { lockMini(); return; }
3530 if (a === "attend") { attendOnly(); return; }
3531
3532 // event choices
3533 if (a.indexOf("ev-") === 0) { chooseEvent(parseInt(a.slice(3), 10)); return; }
3534 }
3535
3536 // work-the-lot sub screen
3537 function viewWork() {
3538 var rows = HUSTLES.map(function (h) {
3539 return btn("hustle-" + h.id, '<span class="ic">🤹</span> ' + esc(h.label), "choice", !h.need(S));
3540 }).join("");
3541 return '<span class="scroll-top"></span>' + statusBar() +
3542 '<h3>Work the lot</h3><p class="dim">Everybody\'s got a hustle. Pick yours.</p>' +
3543 '<div class="choices">' + rows + btn("go-lot", '<span class="ic">◀</span> Back to the van', "choice") + '</div>';
3544 }
3545
3546 // A persistent sound on/off control that lives outside #app, so it survives every
3547 // re-render. It carries no data-action, so the game's click delegate ignores it.
3548 function installMuteButton() {
3549 if (!HAS_DOM) return;
3550 var b = document.createElement("button");
3551 b.id = "mute-btn"; b.type = "button"; b.setAttribute("aria-label", "Toggle sound");
3552 function paint() { b.innerHTML = Sound.isEnabled() ? "🔊" : "🔇"; }
3553 paint();
3554 b.addEventListener("click", function (e) {
3555 e.stopPropagation();
3556 Sound.init(); Sound.resume();
3557 var on = Sound.toggle(); paint();
3558 if (on) Sound.good();
3559 });
3560 document.body.appendChild(b);
3561 }
3562
3563 // ---------- trail-map sidebar ----------
3564 // A persistent, decorative map of the whole Spring '77 route that lives outside
3565 // #app (so it survives every re-render) and tracks the bus down the tour. It only
3566 // shows during actual gameplay; a toggle collapses it (default open on wide
3567 // screens, closed on narrow ones so it never buries the game on a phone).
3568 var mapOpen = false;
3569 var MAP_KEY = "terrapin_map_open";
3570 var MAP_VIEWS = ["lot", "shakedown", "work", "tape", "mini", "van", "stash", "library", "event", "result", "arrive", "sightsee", "driving", "peak", "builder", "crew", "confirm-drive"];
3571 function installTrailMap() {
3572 if (!HAS_DOM) return;
3573 var saved = null;
3574 try { saved = window.localStorage.getItem(MAP_KEY); } catch (e) {}
3575 mapOpen = saved === "1" || (saved === null && window.innerWidth >= 1400);
3576 var toggle = document.createElement("button");
3577 toggle.id = "map-toggle"; toggle.type = "button"; toggle.setAttribute("aria-label", "Toggle tour map");
3578 toggle.innerHTML = "🗺";
3579 toggle.addEventListener("click", function (e) {
3580 e.stopPropagation();
3581 mapOpen = !mapOpen;
3582 try { window.localStorage.setItem(MAP_KEY, mapOpen ? "1" : "0"); } catch (_) {}
3583 updateTrailMap();
3584 });
3585 document.body.appendChild(toggle);
3586 var aside = document.createElement("aside");
3587 aside.id = "trailmap"; aside.setAttribute("aria-label", "Tour map");
3588 document.body.appendChild(aside);
3589 }
3590 function updateTrailMap() {
3591 if (!HAS_DOM) return;
3592 var aside = document.getElementById("trailmap"), toggle = document.getElementById("map-toggle");
3593 if (!aside || !toggle) return;
3594 var v = S ? S.view : "title";
3595 var show = !!(S && S.role && typeof S.stop === "number" && MAP_VIEWS.indexOf(v) !== -1);
3596 document.body.classList.toggle("map-open", show && mapOpen);
3597 if (!show) { aside.style.display = "none"; toggle.style.display = "none"; return; }
3598 toggle.style.display = "block";
3599 toggle.classList.toggle("on", mapOpen);
3600 aside.style.display = mapOpen ? "block" : "none";
3601 if (!mapOpen) return;
3602 var rows = STOPS.map(function (st, i) {
3603 var cls, mark;
3604 if (i < S.stop) { cls = "played"; mark = "✓"; }
3605 else if (i === S.stop) { cls = "here"; mark = "🚛"; }
3606 else if (st.legendary) { cls = "legend-star"; mark = "★"; }
3607 else { cls = "stop"; mark = "○"; }
3608 return '<li class="' + cls + '"><span class="mk">' + mark + '</span> ' + esc(st.city.split(",")[0]) +
3609 (st.legendary ? ' <span class="legend-star">★</span>' : '') +
3610 '<br><span class="mdate">' + esc(st.date) + (i < STOPS.length - 1 ? ' · ' + STOPS[i + 1].miles + 'mi' : ' · finale') + '</span></li>';
3611 }).join("");
3612 aside.innerHTML = '<div class="mcap">The Long Strange Route</div><ol class="mstops">' + rows + '</ol>' +
3613 '<div class="mfoot">🚛 ' + esc(vanTitle(S)) + '</div>';
3614 }
3615 // ---------- "Tonight's Tapes": live Internet Archive recordings sidebar ----------
3616 // A right-hand sidebar that pulls the *real* audience and soundboard recordings of
3617 // the exact show you're parked at from the Internet Archive's Grateful Dead
3618 // collection (the same public advancedsearch endpoint archive.org's own site uses).
3619 // Each recording links out to its own Archive.org page, where Archive's own player
3620 // is embedded -- no embed of our own to keep working. Fully inert under Node;
3621 // browser-only. Results are cached per date.
3622 var musicOpen = false;
3623 var MUSIC_KEY = "terrapin_music_open";
3624 var archiveCache = {}; // ymd -> { loading, error, docs }
3625 function installMusicPanel() {
3626 if (!HAS_DOM) return;
3627 try { musicOpen = window.localStorage.getItem(MUSIC_KEY) === "1"; } catch (e) {}
3628 var toggle = document.createElement("button");
3629 toggle.id = "music-toggle"; toggle.type = "button"; toggle.setAttribute("aria-label", "Toggle tonight's tapes");
3630 toggle.innerHTML = "🎥";
3631 toggle.addEventListener("click", function (e) {
3632 e.stopPropagation();
3633 musicOpen = !musicOpen;
3634 try { window.localStorage.setItem(MUSIC_KEY, musicOpen ? "1" : "0"); } catch (_) {}
3635 updateMusicPanel();
3636 });
3637 document.body.appendChild(toggle);
3638 var box = document.createElement("aside");
3639 box.id = "musicbox"; box.setAttribute("aria-label", "Tonight's tapes on the Internet Archive");
3640 document.body.appendChild(box);
3641 }
3642 // Archive.org's own search, scoped to this tour's year -- used whenever there's
3643 // nothing more specific to link to (an error, no exact-date match, or the
3644 // catch-all footer link), so a click lands somewhere relevant instead of the
3645 // entire multi-decade collection.
3646 function archiveYearBrowseUrl(year) {
3647 return "https://archive.org/search.php?query=" + encodeURIComponent("collection:" + TOUR_COLLECTION + " AND date:[" + year + "-01-01 TO " + year + "-12-31]");
3648 }
3649 function fetchArchive(date) {
3650 archiveCache[date] = { loading: true, error: false, docs: null };
3651 var url = "https://archive.org/advancedsearch.php?q=" +
3652 encodeURIComponent("collection:" + TOUR_COLLECTION + " AND date:" + date) +
3653 "&fl[]=identifier&fl[]=title&fl[]=avg_rating&fl[]=source&rows=30&sort[]=avg_rating+desc&output=json";
3654 fetch(url).then(function (r) { if (!r.ok) throw new Error("archive"); return r.json(); })
3655 .then(function (d) {
3656 archiveCache[date] = { loading: false, error: false, docs: (d.response && d.response.docs) || [] };
3657 if (musicOpen) updateMusicPanel();
3658 })
3659 .catch(function () {
3660 archiveCache[date] = { loading: false, error: true, docs: null };
3661 if (musicOpen) updateMusicPanel();
3662 });
3663 }
3664 function shortTapeTitle(t) {
3665 return String(t || "Untitled recording").replace(/^Grateful Dead\s+(Live\s+)?at\s+/i, "").replace(/\s+on\s+\d{4}-\d{2}-\d{2}.*$/i, "");
3666 }
3667 function renderMusicBox(box, stop, entry) {
3668 var head = '<div class="mcap">🎥 Tonight\'s Tapes</div>' +
3669 '<p class="msub">' + esc(stop.venue) + '<br><span class="mdate">' + esc(stop.city) + ' · ' + esc(stop.date) + ', ' + TOUR_YEAR + '</span></p>' +
3670 // TT-MUSIC: the tour's own pick for this night plays inside the game.
3671 (window.TTMusic && stop.identifier ? '<p><button type="button" class="btn buy ttplay" data-ident="' + esc(stop.identifier) + '">▶ Play tonight\'s show</button></p>' : '');
3672 var body;
3673 if (!entry || entry.loading) {
3674 body = '<p class="dim small">Digging through the Internet Archive…</p>';
3675 } else if (entry.error) {
3676 body = '<p class="dim small">Couldn\'t reach the Archive right now. ' +
3677 extLink(archiveYearBrowseUrl(TOUR_YEAR), "Browse " + TOUR_YEAR + " shows ↗") + '</p>';
3678 } else if (!entry.docs.length) {
3679 body = '<p class="dim small">No tapes catalogued for this night yet. ' +
3680 extLink(archiveYearBrowseUrl(TOUR_YEAR), "Browse " + TOUR_YEAR + " shows ↗") + '</p>';
3681 } else {
3682 var items = entry.docs.slice(0, 20).map(function (doc) {
3683 var rating = doc.avg_rating ? ' <span class="mrate">★ ' + (Math.round(doc.avg_rating * 10) / 10) + '</span>' : '';
3684 var src = doc.source ? '<div class="msrc">' + esc(String(doc.source).slice(0, 80)) + '…</div>' : '';
3685 var det = "https://archive.org/details/" + encodeURIComponent(doc.identifier);
3686 return '<li class="mrec">' +
3687 '<div class="mtitle">' + esc(shortTapeTitle(doc.title)) + rating + '</div>' + src +
3688 '<div class="mrecbtns">' + (window.TTMusic ? '<button type="button" class="btn buy ttplay" data-ident="' + esc(doc.identifier) + '">▶ Play in the van</button> ' : '') +
3689 '<a class="btn mplay" href="' + det + '" target="_blank" rel="noopener noreferrer">Archive ↗</a></div>' +
3690 '</li>';
3691 }).join("");
3692 body = '<ol class="mrecs">' + items + '</ol>';
3693 }
3694 var foot = '<div class="mfoot">' + extLink(archiveYearBrowseUrl(TOUR_YEAR), "Browse all of " + TOUR_YEAR + " ↗") + '</div>';
3695 box.innerHTML = head + body + foot;
3696 }
3697 function updateMusicPanel() {
3698 if (!HAS_DOM) return;
3699 var box = document.getElementById("musicbox"), toggle = document.getElementById("music-toggle");
3700 if (!box || !toggle) return;
3701 var v = S ? S.view : "title";
3702 var show = !!(S && S.role && typeof S.stop === "number" && MAP_VIEWS.indexOf(v) !== -1);
3703 document.body.classList.toggle("music-open", show && musicOpen);
3704 if (!show) { box.style.display = "none"; toggle.style.display = "none"; return; }
3705 toggle.style.display = "block";
3706 toggle.classList.toggle("on", musicOpen);
3707 box.style.display = musicOpen ? "block" : "none";
3708 if (!musicOpen) return;
3709 var stop = STOPS[S.stop];
3710 if (!archiveCache[stop.ymd]) fetchArchive(stop.ymd);
3711 var entry = archiveCache[stop.ymd];
3712 // Only rebuild the panel when its content actually changes -- otherwise a plain
3713 // game re-render would wipe the box's innerHTML and kill a playing embed. The
3714 // signature covers the show and the load state; while it's steady we leave the
3715 // DOM (and any playing Archive iframe) untouched.
3716 var sig = stop.ymd + "|" + (!entry || entry.loading ? "load" : entry.error ? "err" : "ok" + entry.docs.length);
3717 if (sig !== musicSig) { musicSig = sig; renderMusicBox(box, stop, entry); }
3718 }
3719 var musicSig = null;
3720
3721 // Live renaming: update crew names as the player types, without a re-render
3722 // (which would steal focus mid-keystroke). State stays in sync for the next paint.
3723 function onInput(e) {
3724 var t = e.target;
3725 if (!t || !t.getAttribute) return;
3726 if (t.getAttribute("data-crew")) { setCrewName(t.getAttribute("data-crew"), t.value); return; }
3727 // Live van renaming: update state without a re-render so focus isn't lost.
3728 if (t.hasAttribute && t.hasAttribute("data-van-name")) { S.vanName = t.value; }
3729 }
3730
3731 // ---------- boot ----------
3732 // ---------- options & accessibility (always available) ----------
3733 // A gear button, present on every screen, opens a panel that persists to
3734 // localStorage: a readable font, phosphor color themes, high-contrast mode, a
3735 // motion/glow kill switch, and a screen-reader mode (hides ASCII art, announces
3736 // each screen). All applied as classes on <body> plus an aria-live on #app.
3737 var OPTS_KEY = "terrapin_opts";
3738 var OPTS = { font: "pixel", theme: "green", contrast: false, motion: true, sr: false, hints: false, textSize: "normal" };
3739 var THEMES = ["green", "amber", "cyan", "white"];
3740 function loadOpts() {
3741 if (!HAS_DOM) return;
3742 try { var o = JSON.parse(window.localStorage.getItem(OPTS_KEY) || "{}"); if (o && typeof o === "object") { for (var k in OPTS) if (k in o) OPTS[k] = o[k]; } } catch (e) {}
3743 }
3744 function saveOpts() { if (!HAS_DOM) return; try { window.localStorage.setItem(OPTS_KEY, JSON.stringify(OPTS)); } catch (e) {} }
3745 function applyOpts() {
3746 if (!HAS_DOM) return;
3747 var b = document.body;
3748 b.classList.toggle("readable-font", OPTS.font === "readable");
3749 THEMES.forEach(function (t) { b.classList.toggle("theme-" + t, OPTS.theme === t); });
3750 b.classList.toggle("high-contrast", !!OPTS.contrast);
3751 b.classList.toggle("no-motion", !OPTS.motion);
3752 b.classList.toggle("sr-mode", !!OPTS.sr);
3753 b.classList.toggle("text-large", OPTS.textSize === "large");
3754 b.classList.toggle("text-xl", OPTS.textSize === "xl");
3755 var a = document.getElementById("app");
3756 if (a) { if (OPTS.sr) { a.setAttribute("aria-live", "polite"); a.setAttribute("role", "region"); } else { a.removeAttribute("aria-live"); a.removeAttribute("role"); } }
3757 applyShortcutHints();
3758 }
3759 // ---------- how-to-play sidebar (static tutorial, always available) ----------
3760 var helpOpen = false;
3761 function helpHtml() {
3762 function s(cap, body) { return '<div class="hsec"><div class="hcap">' + cap + '</div><p>' + body + '</p></div>'; }
3763 return '<div class="mcap">🎣 How to play</div>' +
3764 s("The goal", "Follow the band's tour city to city. Catch (and tape) every show, keep the crew and the van alive, and reach the finale without getting dosed into next Tuesday.") +
3765 s("🌟 Vibes", "Your morale. It drops on the road and when you're hungry; if it bottoms out, van-mates quit and eventually the run ends. Shows and a rest day bring it back up.") +
3766 s("⛽ Gas & 🍝 Grub", "Each leg burns gas (buy it on Shakedown) and feeds the crew. A bigger van and richer rations eat more grub — run dry and Vibes crash.") +
3767 s("🚛 Pace & the schedule", "Shows happen on real dates. Watch the ETA on the lot: too slow a pace and you'll MISS the next show. Beat it and the spare days are yours to go sightseeing.") +
3768 s("🎟 Shakedown Street", "Buy, sell and trade food, charms, tapes and van parts. Prices swing lot to lot — a cheap night is a good time to stock up. Sell spare gear back for cash.") +
3769 s("🎤 Taping the show", "Get a ticket, pick WHERE (soundboard = best/riskiest, crowd = safe/rough) and WHEN (the encore pays best but is tightest), then lock the needle in the sweet spot. Chain dead-center locks for a combo.") +
3770 s("🔧 The van & 👥 crew", "Install parts for perks (more Vibes, safer taping, bigger tanks). More van-mates means more hands at the show and on the lot — but more mouths to feed.") +
3771 s("💡 Tip", "Tap the ⚙ gear (top-left) any time for a readable font, high-contrast and other options — or to leave to the home screen. Your trip auto-saves at each lot.") +
3772 s("⌘ Keyboard", "Number keys 1-9 pick a choice on screen (turn on hint badges in Options). M opens the map, O opens Options, H opens this, S toggles sound, Esc closes a panel.") +
3773 '<div class="mfoot"><button class="btn" data-helpclose="1">Got it</button></div>';
3774 }
3775 function updateHelp() {
3776 if (!HAS_DOM) return;
3777 var a = document.getElementById("help"), t = document.getElementById("help-toggle");
3778 if (!a || !t) return;
3779 t.classList.toggle("on", helpOpen);
3780 t.setAttribute("aria-expanded", helpOpen ? "true" : "false");
3781 a.style.display = helpOpen ? "block" : "none";
3782 if (helpOpen && !a.getAttribute("data-built")) { a.innerHTML = helpHtml(); a.setAttribute("data-built", "1"); }
3783 }
3784 function installHelp() {
3785 if (!HAS_DOM) return;
3786 var t = document.createElement("button");
3787 t.id = "help-toggle"; t.type = "button"; t.setAttribute("aria-label", "How to play"); t.setAttribute("aria-expanded", "false");
3788 t.innerHTML = "?";
3789 t.addEventListener("click", function (e) { e.stopPropagation(); helpOpen = !helpOpen; updateHelp(); });
3790 document.body.appendChild(t);
3791 var a = document.createElement("aside");
3792 a.id = "help"; a.setAttribute("aria-label", "How to play");
3793 a.addEventListener("click", function (e) { var c = e.target.closest && e.target.closest("[data-helpclose]"); if (c) { e.stopPropagation(); helpOpen = false; updateHelp(); } });
3794 document.body.appendChild(a);
3795 }
3796
3797 function optsPanelHtml() {
3798 function seg(opt, val, label, cur) { return '<button class="btn ' + (cur ? "" : "ghost") + '" data-opt="' + opt + '"' + (val != null ? ' data-val="' + val + '"' : '') + '>' + (cur ? "● " : "◯ ") + label + '</button>'; }
3799 return '<div class="mcap">⚙ Options & Accessibility</div>' +
3800 '<p class="small dim">Font</p><p>' + seg("font", "pixel", "Retro pixel", OPTS.font === "pixel") + " " + seg("font", "readable", "Readable", OPTS.font === "readable") + '</p>' +
3801 '<p class="small dim">Text size</p><p>' +
3802 seg("textSize", "normal", "Normal", OPTS.textSize === "normal") + " " +
3803 seg("textSize", "large", "Large", OPTS.textSize === "large") + " " +
3804 seg("textSize", "xl", "Extra large", OPTS.textSize === "xl") + '</p>' +
3805 '<p class="small dim">Phosphor color</p><p class="optthemes">' + THEMES.map(function (t) { return seg("theme", t, t.charAt(0).toUpperCase() + t.slice(1), OPTS.theme === t); }).join(" ") + '</p>' +
3806 '<p class="small dim">Display</p>' +
3807 '<p>' + seg("contrast", null, "High contrast: " + (OPTS.contrast ? "ON" : "off"), OPTS.contrast) + '</p>' +
3808 '<p>' + seg("motion", null, "Motion & glow: " + (OPTS.motion ? "on" : "OFF"), !OPTS.motion) + '</p>' +
3809 '<p class="small dim">Screen reader</p>' +
3810 '<p>' + seg("sr", null, "Reader mode: " + (OPTS.sr ? "ON" : "off"), OPTS.sr) + '<br><span class="dim small">Hides the ASCII art and announces each screen.</span></p>' +
3811 '<p class="small dim">Keyboard</p>' +
3812 '<p>' + seg("hints", null, "Shortcut key hints: " + (OPTS.hints ? "ON" : "off"), OPTS.hints) + '<br>' +
3813 '<span class="dim small">Number keys pick a highlighted choice. M map, O options, H help, S sound, Esc closes a panel.</span></p>' +
3814 '<div class="mfoot"><button class="btn" data-optquit="1">🏠 Leave to home screen</button> <button class="btn" data-optclose="1">Close</button>' +
3815 '<p class="dim small">Your trip is saved — pick Continue on the home screen to jump back in.</p></div>';
3816 }
3817 var optsOpen = false;
3818 function renderOptsPanel() {
3819 var panel = document.getElementById("optspanel"), gear = document.getElementById("opts-btn");
3820 if (!panel || !gear) return;
3821 gear.classList.toggle("on", optsOpen);
3822 gear.setAttribute("aria-expanded", optsOpen ? "true" : "false");
3823 panel.style.display = optsOpen ? "block" : "none";
3824 if (optsOpen) panel.innerHTML = optsPanelHtml();
3825 }
3826 function onOptsClick(e) {
3827 var quit = e.target.closest ? e.target.closest("[data-optquit]") : null;
3828 if (quit) { e.stopPropagation(); optsOpen = false; renderOptsPanel(); if (!S) S = {}; S.view = "title"; render(); return; }
3829 var close = e.target.closest ? e.target.closest("[data-optclose]") : null;
3830 if (close) { e.stopPropagation(); optsOpen = false; renderOptsPanel(); return; }
3831 var t = e.target.closest ? e.target.closest("[data-opt]") : null;
3832 if (!t) return;
3833 e.stopPropagation();
3834 var opt = t.getAttribute("data-opt"), val = t.getAttribute("data-val");
3835 if (opt === "font" || opt === "theme" || opt === "textSize") OPTS[opt] = val;
3836 else OPTS[opt] = !OPTS[opt];
3837 saveOpts(); applyOpts(); renderOptsPanel();
3838 }
3839 function installOptions() {
3840 if (!HAS_DOM) return;
3841 var gear = document.createElement("button");
3842 gear.id = "opts-btn"; gear.type = "button"; gear.setAttribute("aria-label", "Options and accessibility");
3843 gear.setAttribute("aria-expanded", "false"); gear.innerHTML = "⚙";
3844 gear.addEventListener("click", function (e) { e.stopPropagation(); optsOpen = !optsOpen; renderOptsPanel(); });
3845 document.body.appendChild(gear);
3846 var panel = document.createElement("aside");
3847 panel.id = "optspanel"; panel.setAttribute("aria-label", "Options and accessibility");
3848 panel.addEventListener("click", onOptsClick);
3849 document.body.appendChild(panel);
3850 }
3851
3852 function boot() {
3853 applyGeneratedTour(); // TT-INFINITY
3854 app = document.getElementById("app");
3855 document.addEventListener("click", onClick);
3856 document.addEventListener("input", onInput);
3857 document.addEventListener("keydown", onKeyDown);
3858 loadOpts();
3859 installMuteButton();
3860 installTrailMap();
3861 installMusicPanel();
3862 installOptions();
3863 installHelp();
3864 applyOpts();
3865 S = { view: "title" };
3866 loadHall(function () {});
3867 render();
3868 // TT-MUSIC: let the play page's music dock ask which night the van is at.
3869 window.TTGame = {
3870 currentStop: function () { return S && S.role && STOPS && typeof S.stop === "number" ? STOPS[S.stop] : null; },
3871 // TT-THEME: the archive browser's color picker shares this setting.
3872 setTheme: function (t) { if (THEMES.indexOf(t) === -1) return; OPTS.theme = t; applyOpts(); },
3873 // TT-BUG: a short, readable snapshot for bug reports.
3874 debugState: function () {
3875 if (!S || !S.role) return { screen: S ? S.view : "title" };
3876 var stop = STOPS && STOPS[S.stop];
3877 return { screen: S.view, role: S.role, day: S.day, stop: (S.stop + 1) + "/" + (STOPS ? STOPS.length : "?") + (stop ? " " + stop.city : ""),
3878 cash: S.cash, gas: S.gas, grub: S.grub, van: S.van, vibes: S.vibes, cred: S.cred, tapes: S.tapes };
3879 },
3880 };
3881 }
3882 if (HAS_DOM) {
3883 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
3884 else boot();
3885 } else if (typeof globalThis !== "undefined") {
3886 // Headless (Node) test surface. No logic is duplicated -- the harness drives
3887 // the exact same functions the browser does. A tiny seedable PRNG (mulberry32)
3888 // makes runs deterministic; setSeed(null) restores Math.random.
3889 function mulberry32(seed) {
3890 var a = seed >>> 0;
3891 return function () {
3892 a |= 0; a = (a + 0x6D2B79F5) | 0;
3893 var t = Math.imul(a ^ (a >>> 15), 1 | a);
3894 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
3895 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
3896 };
3897 }
3898 globalThis.TerrapinTrail = {
3899 // data
3900 ROLES: ROLES, STOPS: STOPS, EVENTS: EVENTS, MODS: MODS, MOD_SLOTS: MOD_SLOTS,
3901 TOURS: TOURS, setTour: setTour, tourByYear: tourByYear, currentTourYear: function () { return TOUR_YEAR; },
3902 VAN_PARTS: VAN_PARTS, CONSUMABLES: CONSUMABLES, LOT_ITEMS: LOT_ITEMS,
3903 HUSTLES: HUSTLES, PEAKS: PEAKS, CAP: CAP, ACHIEVEMENTS: ACHIEVEMENTS,
3904 VANMATES: VANMATES, CREW_MIN: CREW_MIN, CREW_MAX: CREW_MAX,
3905 rosterName: function (id) { return rosterName(S, id); },
3906 toggleVanmate: toggleVanmate, setCrewName: setCrewName, rebuildCrew: rebuildCrew, crewHas: crewHas,
3907 serializeSave: serializeSave, restoreState: applySave,
3908 // deterministic RNG control
3909 setSeed: function (n) { rand = (n === null || n === undefined) ? Math.random : mulberry32(n); },
3910 // achievements: evaluate a finished run, inspect unlock state, reset between tests
3911 evaluateAchievements: evaluateAchievements, isUnlocked: isUnlocked, unlockedCount: unlockedCount,
3912 maybePoolCrew: maybePoolCrew,
3913 resetAchievements: function () { UNLOCKED = {}; if (HAS_DOM) { try { window.localStorage.removeItem(ACH_KEY); } catch (e) {} } },
3914 // state access
3915 getState: function () { return S; },
3916 setHall: function (runs) { HALL = runs; },
3917 setupFromState: setupFromState, applySetup: applySetup,
3918 setCampaignLen: function (n) { PENDING_CAMPAIGN_LEN = n; }, campaignYearsFrom: campaignYearsFrom,
3919 // lifecycle + actions (same entry points the UI uses)
3920 newGame: newGame, roleById: function (id) { return ROLES.filter(function (r) { return r.id === id; })[0]; },
3921 toggleMod: toggleMod, buyGear: buyGear, sellGear: sellGear, finalizeBuild: finalizeBuild,
3922 installPart: installPart, uninstallSlot: uninstallSlot, useConsumable: useConsumable,
3923 recomputeBuffs: recomputeBuffs, legNeeds: legNeeds, driveOn: driveOn,
3924 finishDrive: finishDrive, resumeLeg: resumeLeg, chooseEvent: chooseEvent,
3925 startTaping: startTaping, lockMini: lockMini, attendOnly: attendOnly,
3926 tryMiracle: tryMiracle, doHustle: doHustle, buyItem: buyItem, rest: rest,
3927 startSellTape: startSellTape, haggleClose: haggleClose, haggleCounter: haggleCounter, tapeOffer: tapeOffer,
3928 buyItemMax: buyItemMax, storeBuyMax: storeBuyMax, buyGearMax: buyGearMax,
3929 continueResult: continueResult, handle: handle, computeScore: computeScore,
3930 // audio probe -- always false under Node, proving the layer stays inert
3931 audioActive: Sound.active,
3932 // render the current view to an HTML string, no DOM required
3933 viewHtml: viewHtml
3934 };
3935 }
3936})();
3937