M: Music
'],\n })\n }\n\n await browser.close()\n return { sitemap, authFlows: this.authFlows, formResults: this.results }\n },\n})","language":"typescript"},"narration":"Fort Laramie is where Shannon gets hands-on. Playwright automates a real browser — navigating pages, filling forms, testing login flows. It's not just crawling links; it's interacting like a real user would. Every form submission, every auth flow gets mapped as potential attack surface."},{"name":"Independence Rock","subtitle":"Attack Surface Analysis","concept":"Exploitation","landmarkType":"mountain","code":{"file":"src/agents/analyzer.ts","content":"// src/agents/analyzer.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const attackSurfaceAgent = new Agent({\n name: 'attack-surface-analyzer',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, recon, exploration }) {\n // Run specialized analysis agents in parallel\n const [sqlResults, xssResults, authResults, ssrfResults] =\n await Promise.all([\n this.spawn('sql-injection-analyzer', {\n codeRoutes: recon.entryPoints,\n formResults: exploration.formResults,\n ormType: recon.techStack.orm,\n }),\n this.spawn('xss-analyzer', {\n templates: recon.architecture.views,\n formResults: exploration.formResults,\n framework: recon.techStack.frontend,\n }),\n this.spawn('auth-bypass-analyzer', {\n authFlows: exploration.authFlows,\n jwtConfig: recon.architecture.auth,\n sessionMgmt: recon.architecture.sessions,\n }),\n this.spawn('ssrf-analyzer', {\n urlParams: exploration.sitemap.urlParams,\n serverCode: recon.architecture.httpClients,\n }),\n ])\n\n return this.prioritize([...sqlResults, ...xssResults,\n ...authResults, ...ssrfResults])\n },\n})","language":"typescript"},"narration":"Independence Rock is where insights converge. Parallel agents analyze the combined results — one for SQL injection, one for XSS, one for auth bypass, one for SSRF. Each agent combines code analysis with exploration data. Static tells you where the code is vulnerable; dynamic tells you if you can reach it."},{"name":"South Pass","subtitle":"Injection Testing","concept":"Exploitation","landmarkType":"river","code":{"file":"src/agents/injector.ts","content":"// src/agents/injector.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const injectionAgent = new Agent({\n name: 'injection-tester',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, hypotheses }) {\n const findings = []\n\n for (const hypothesis of hypotheses.sqlInjection) {\n // Code-aware payload crafting\n const payload = this.craftPayload({\n ormType: hypothesis.orm, // Sequelize? Raw SQL?\n paramType: hypothesis.paramType, // query string? body?\n dbType: hypothesis.database, // PostgreSQL? MySQL?\n escaping: hypothesis.escaping, // What sanitization exists?\n })\n\n const result = await this.inject(target.appUrl, {\n endpoint: hypothesis.endpoint,\n method: hypothesis.method,\n param: hypothesis.param,\n payload: payload,\n })\n\n if (result.indicates.sqli) {\n findings.push({\n type: 'SQL_INJECTION',\n severity: 'CRITICAL',\n endpoint: hypothesis.endpoint,\n payload: payload,\n evidence: result.response,\n codeRef: hypothesis.sourceFile + ':' + hypothesis.sourceLine,\n })\n }\n }\n\n return findings\n },\n})","language":"typescript"},"narration":"South Pass — the narrow gap where payloads squeeze through. Shannon doesn't spray generic payloads blindly. It reads the source code to understand which ORM is used, what sanitization exists, and crafts targeted payloads. Knowing the code means knowing exactly where raw SQL meets user input."},{"name":"Fort Bridger","subtitle":"Auth Bypass","concept":"Proof-by-Exploit","landmarkType":"forest","code":{"file":"src/agents/auth-bypass.ts","content":"// src/agents/auth-bypass.ts\nimport { Agent } from '@shannon/agent-sdk'\nimport { decode, sign, verify } from 'jsonwebtoken'\n\nexport const authBypassAgent = new Agent({\n name: 'auth-bypass',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, jwtConfig }) {\n const findings = []\n const token = await this.obtainValidToken(target)\n const decoded = decode(token, { complete: true })\n\n // Test alg:none attack\n const noneToken = sign(\n { ...decoded.payload, role: 'admin' },\n '',\n { algorithm: 'none' }\n )\n const noneResult = await this.testToken(target, noneToken)\n if (noneResult.accepted) {\n findings.push({\n type: 'JWT_ALG_NONE',\n severity: 'CRITICAL',\n description: 'Server accepts unsigned JWT tokens',\n exploit: `curl -H \"Authorization: Bearer ${noneToken}\" ${target.appUrl}/api/admin`,\n })\n }\n\n // Test algorithm confusion (RS256 -> HS256)\n if (decoded.header.alg === 'RS256' && jwtConfig.publicKey) {\n const confusedToken = sign(\n { ...decoded.payload, role: 'admin' },\n jwtConfig.publicKey,\n { algorithm: 'HS256' }\n )\n const confusedResult = await this.testToken(target, confusedToken)\n if (confusedResult.accepted) {\n findings.push({\n type: 'JWT_ALG_CONFUSION',\n severity: 'CRITICAL',\n description: 'RS256/HS256 algorithm confusion',\n })\n }\n }\n\n return findings\n },\n})","language":"typescript"},"narration":"Fort Bridger guards the authentication frontier. JWT attacks are Shannon's specialty here — the alg:none attack sets the algorithm to nothing, bypassing signature checks on misconfigured servers. Algorithm confusion tricks RS256 verification into accepting HS256 signed with the public key. Authentication is the crown jewels of security."},{"name":"The Dalles","subtitle":"Exploit Validation","concept":"Proof-by-Exploit","landmarkType":"river","code":{"file":"src/agents/validator.ts","content":"// src/agents/validator.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const exploitValidator = new Agent({\n name: 'exploit-validator',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, findings }) {\n const validated = []\n\n for (const finding of findings) {\n // Actually execute the exploit — not just flag it\n const exploitResult = await this.exploit(target, finding, {\n timeout: 30_000,\n maxRetries: 3,\n })\n\n if (exploitResult.success) {\n // Reproduce it twice more to confirm\n const repro1 = await this.exploit(target, finding)\n const repro2 = await this.exploit(target, finding)\n\n if (repro1.success && repro2.success) {\n validated.push({\n ...finding,\n status: 'CONFIRMED',\n reproducible: true,\n exploitSteps: exploitResult.steps,\n curlCommand: this.generateCurl(finding),\n evidence: {\n request: exploitResult.request,\n response: exploitResult.response,\n screenshot: exploitResult.screenshot,\n },\n })\n }\n }\n }\n\n return validated\n },\n})","language":"typescript"},"narration":"The Dalles is where theory meets reality. Shannon doesn't just flag potential vulnerabilities — it exploits them. Three successful reproductions confirm a finding. No exploit, no report. This eliminates the false positives that plague traditional scanners and gives defenders reproducible proof."},{"name":"Oregon City","subtitle":"Report Generation","concept":"Browser Automation","landmarkType":"town","code":{"file":"src/report/generator.ts","content":"// src/report/generator.ts\nimport { ValidatedFinding } from '@shannon/types'\n\nexport function generateReport(\n findings: ValidatedFinding[],\n target: PentestTarget,\n): SecurityReport {\n return {\n executive_summary: summarize(findings),\n target: {\n name: target.name,\n url: target.appUrl,\n scope: target.scope,\n },\n findings: findings.map(f => ({\n title: f.type,\n severity: f.severity,\n description: f.description,\n // Copy-paste ready exploit command\n reproduction: {\n steps: f.exploitSteps,\n curl: f.curlCommand,\n evidence: f.evidence,\n },\n remediation: generateRemediation(f),\n references: getCVEReferences(f),\n })),\n methodology: 'Autonomous white-box penetration test',\n tools: ['Shannon AI', 'Playwright', 'Nmap', 'WhatWeb'],\n }\n}","language":"typescript"},"narration":"You made it to Oregon City! The final report is what separates Shannon from noise-generating scanners. Every finding has copy-paste exploit commands, screenshots of successful exploitation, and specific remediation steps. An actionable security report, not a 500-page dump of theoretical alerts."}],"events":[{"type":"weather","trigger":"after_stop","triggerStop":1,"title":"Scope Storm!","text":"A storm of targets hits your terminal — production servers, staging environments, third-party APIs, internal tools. Your scope definition is being tested. Why does Shannon require source code access for white-box testing?","choices":[{"text":"Code analysis guides attack strategy and reveals vulnerabilities invisible to blind testing","correct":true,"explanation":"White-box testing means Shannon reads the source code to find where raw SQL meets user input, where auth is misconfigured, where secrets are hardcoded. Black-box testing can only guess."},{"text":"Shannon can't use a browser without source code","correct":false,"explanation":"Shannon uses Playwright for browser automation regardless of code access. Source code enables targeted testing, not browser control."},{"text":"Source code is easier to read than HTTP responses","correct":false,"explanation":"It's not about ease of reading — it's about finding vulnerabilities that are invisible from the outside, like unsafe ORM usage or hardcoded secrets."}],"concept":"Reconnaissance","difficulty":"easy"},{"type":"encounter","trigger":"after_stop","triggerStop":2,"title":"A Security Scout Appears!","text":"A grizzled security researcher approaches your camp. 'I see you're running Nmap and WhatWeb against the target. Good start.' What do these tools discover about the target?","choices":[{"text":"Open ports, services, technology stack, and framework versions","correct":true,"explanation":"Nmap discovers open ports and running services. WhatWeb fingerprints the technology stack — web server, framework, programming language, CMS versions. Together they map the attack surface."},{"text":"Passwords stored in the application database","correct":false,"explanation":"Nmap and WhatWeb are reconnaissance tools, not exploitation tools. They discover services and technologies, not credentials."},{"text":"Source code vulnerabilities like SQL injection","correct":false,"explanation":"These are network and technology fingerprinting tools. Source code analysis is a separate phase that Shannon handles with its code analysis agent."}],"concept":"Reconnaissance","difficulty":"easy"},{"type":"river","trigger":"after_stop","triggerStop":4,"title":"The Vulnerability River Crossing!","text":"You've reached the widest river on the trail — the gap between hypothesis and proof. Your analysis agents have generated dozens of potential vulnerabilities. Why does Shannon run analysis agents in parallel?","choices":[{"text":"Each vulnerability type can be analyzed independently, reducing total time","correct":true,"explanation":"SQL injection analysis doesn't depend on XSS analysis. Auth bypass doesn't need SSRF results. Running them in parallel via Promise.all cuts wall-clock time dramatically."},{"text":"One agent isn't smart enough to find all vulnerability types","correct":false,"explanation":"It's about efficiency, not intelligence. A single agent could analyze all types sequentially — parallelism is an optimization, not a capability requirement."},{"text":"Parallel execution is always faster than sequential","correct":false,"explanation":"Parallel is only faster when tasks are independent. If one analysis depended on another's output, you'd need sequential execution. Shannon's architecture ensures independence."}],"concept":"Exploitation","difficulty":"medium"},{"type":"weather","trigger":"after_stop","triggerStop":5,"title":"Injection Storm!","text":"Payloads are flying everywhere — SQL fragments, XSS vectors, template injections. The storm tests your understanding. How does code-aware testing improve SQL injection detection compared to blind fuzzing?","choices":[{"text":"Knowing the ORM and query builder reveals which inputs reach raw SQL vs parameterized queries","correct":true,"explanation":"If the code uses parameterized queries everywhere, Shannon skips those endpoints. If it finds raw string concatenation in SQL, it knows exactly which parameter to target and what database dialect to exploit."},{"text":"It makes the payloads longer and more complex","correct":false,"explanation":"Code-aware testing makes payloads more targeted, not longer. Understanding the ORM means fewer, smarter payloads instead of brute-force lists."},{"text":"It bypasses Web Application Firewalls automatically","correct":false,"explanation":"WAF bypass is a separate technique. Code-aware testing helps identify truly vulnerable code paths, not evade security controls."}],"concept":"Exploitation","difficulty":"medium"},{"type":"misfortune","trigger":"after_stop","triggerStop":6,"title":"Auth Bypass Gone Wrong!","text":"Your JWT manipulation backfired — the server returned a cryptic error and locked your test account. Before trying again, you need to understand the attack. What is the JWT alg:none attack?","choices":[{"text":"Setting the JWT algorithm to \"none\" to bypass signature verification on misconfigured servers","correct":true,"explanation":"The JWT spec technically allows algorithm \"none\" for trusted contexts. Misconfigured servers that don't validate the algorithm field will accept unsigned tokens, letting attackers forge any claims."},{"text":"Using no JWT at all and accessing endpoints directly","correct":false,"explanation":"The alg:none attack still sends a JWT — it just sets the algorithm header to \"none\" so the signature is empty. The token structure is preserved."},{"text":"Encrypting the JWT with a null cipher so nobody can read it","correct":false,"explanation":"JWTs are signed, not encrypted (unless using JWE). The alg:none attack removes the signature, it doesn't add encryption."}],"concept":"Proof-by-Exploit","difficulty":"hard"},{"type":"encounter","trigger":"after_stop","triggerStop":7,"title":"Proof-by-Exploitation Pioneer!","text":"A veteran pentester at The Dalles checkpoint asks the critical question: 'Why does Shannon require actual exploitation instead of just flagging potential issues?'","choices":[{"text":"Eliminates false positives — proves real-world exploitability with reproducible steps","correct":true,"explanation":"Traditional scanners flag thousands of 'potential' issues. Shannon exploits each finding three times to confirm it's real and reproducible. Defenders get actionable proof, not noise."},{"text":"Actual exploitation is more dramatic and impressive in reports","correct":false,"explanation":"It's not about drama — it's about accuracy. A finding with a working exploit and curl command is infinitely more useful than a 'possible vulnerability' warning."},{"text":"Static analysis is always wrong so you must exploit everything","correct":false,"explanation":"Static analysis is valuable for finding potential issues. But without exploitation, you don't know which findings are actually exploitable in the running application."}],"concept":"Proof-by-Exploit","difficulty":"medium"}],"partyMembers":[{"name":"Reconnaissance","icon":"server","maxHealth":3},{"name":"Exploitation","icon":"router","maxHealth":3},{"name":"Browser Automation","icon":"data","maxHealth":3},{"name":"Proof-by-Exploit","icon":"layout","maxHealth":3}],"deathMessages":["Died of false positives. Your report was 500 pages of noise.","Lost in the authentication maze \u2014 2FA was too strong.","Drowned in rate limiting. The WAF blocked all your payloads.","Killed by your own SQL injection. The database dropped itself.","Your Playwright session crashed. The browser ate all the RAM.","Starved waiting for Temporal workflow to resume. Durable execution wasn't so durable.","Died of scope creep. You started pentesting the wrong application.","Buried under a mountain of unexploitable CVEs."]}; // ===================================================================== // GAME STATE // ===================================================================== const STATES = { TITLE: 'TITLE', SETUP: 'SETUP', TRAVEL: 'TRAVEL', STOP: 'STOP', EVENT: 'EVENT', RIVER: 'RIVER', DEATH: 'DEATH', WIN: 'WIN' }; const PROFESSIONS = [ { name: 'Ralph Wiggum', desc: '"I\'m learnding!"', health: 999, supplies: 999, hintFree: true, forgiving: true }, { name: 'Vibe Coder', desc: '"It works on my machine"', health: 100, supplies: 5, hintFree: false, forgiving: false }, { name: 'Engineer', desc: '"Let me check the docs"', health: 80, supplies: 2, hintFree: false, forgiving: false }, { name: 'Staff Architect', desc: '"I designed this system"', health: 50, supplies: 0, hintFree: false, forgiving: false } ]; let gameState = STATES.TITLE; let difficulty = 1; let health = 100; let maxHealthForGame = 100; let supplies = 5; let currentStop = 0; let day = 1; let score = 0; let totalQuestions = 0; let streak = 0; let bestStreak = 0; let hintsUsed = 0; let scrollX = 0; let travelTimer = null; let animFrame = null; let partyHealth = []; let pendingEvents = []; let currentEventIndex = 0; let eventAnswered = false; let eventHinted = false; let dimmedChoice = -1; let selectedEventChoice = 0; let selectedStopChoice = 0; let shuffledIndices = []; // maps display position -> original choice index let selectedDifficulty = 1; let musicPlaying = false; let audioCtx = null; let musicInitialized = false; let typewriterTimer = null; let typewriterText = ''; let typewriterIndex = 0; let showRiver = false; let wagonWheelAngle = 0; let travelFlavorIndex = 0; let deathPending = false; let currentEventType = ''; // event type for canvas overlay let currentEventTitle = ''; // event title for canvas overlay const travelFlavors = [ "The Playwright session is stable today...", "Payloads flow smoothly through the request pipeline...", "Your Nmap scans return clean results...", "The target's WAF seems asleep...", "A gentle breeze carries HTTP responses...", "The vulnerability scanner hums along quietly...", "Exploit proofs reproduce without issues...", "The pentesting framework handles traffic with grace...", "Your TypeScript agents coordinate perfectly...", "The Temporal workflow is calm and responsive...", "A hawk circles overhead, watching your network traffic...", "The attack surface is well-mapped ahead...", "Your JWT tokens are freshly forged...", "The dependency tree reveals interesting packages...", "Claude analyzes the code with focused precision..." ]; // ===================================================================== // CANVAS RENDERING (320x200 internal) // ===================================================================== const canvas = document.getElementById('game-canvas'); const ctx = canvas.getContext('2d'); const W = 320, H = 200; // Seeded PRNG for consistent landscape function seededRandom(seed) { let s = seed; return function() { s = (s * 1664525 + 1013904223) & 0xFFFFFFFF; return (s >>> 0) / 0xFFFFFFFF; }; } const rng = seededRandom(42); const cloudPositions = []; for (let i = 0; i < 5; i++) { cloudPositions.push({ x: rng() * 400, y: 10 + rng() * 30, w: 25 + rng() * 30, h: 8 + rng() * 6 }); } const farMountains = []; for (let i = 0; i < 6; i++) { farMountains.push({ x: i * 70 - 20 + rng() * 30, h: 40 + rng() * 35, w: 50 + rng() * 40 }); } const nearMountains = []; for (let i = 0; i < 8; i++) { nearMountains.push({ x: i * 55 - 30 + rng() * 20, h: 25 + rng() * 25, w: 30 + rng() * 25, snow: rng() > 0.3 }); } const treesData = []; for (let i = 0; i < 25; i++) { treesData.push({ x: rng() * 600, h: 12 + rng() * 16 }); } function getSkyColors() { const progress = currentStop / Math.max(TRAIL_DATA.stops.length, 1); if (progress < 0.15) { return { top: '#5500AA', bottom: '#FF5555' }; } else if (progress < 0.7) { return { top: '#5555FF', bottom: '#55FFFF' }; } else { return { top: '#5500AA', bottom: '#FFAA00' }; } } function drawPixelCloud(x, y, w, h) { ctx.fillStyle = '#FFFFFF'; const ix = Math.floor(x), iy = Math.floor(y), iw = Math.floor(w), ih = Math.floor(h); ctx.fillRect(ix, iy, iw, ih); ctx.fillRect(ix + Math.floor(iw * 0.15), iy - Math.floor(ih * 0.5), Math.floor(iw * 0.3), Math.floor(ih * 0.6)); ctx.fillRect(ix + Math.floor(iw * 0.4), iy - Math.floor(ih * 0.7), Math.floor(iw * 0.35), Math.floor(ih * 0.8)); ctx.fillRect(ix + Math.floor(iw * 0.65), iy - Math.floor(ih * 0.3), Math.floor(iw * 0.2), Math.floor(ih * 0.4)); } function drawMountain(x, h, w, color, snowCap) { const baseY = 110; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(Math.floor(x), baseY); ctx.lineTo(Math.floor(x + w / 2), Math.floor(baseY - h)); ctx.lineTo(Math.floor(x + w), baseY); ctx.closePath(); ctx.fill(); if (snowCap) { ctx.fillStyle = '#FFFFFF'; const peakX = x + w / 2; const peakY = baseY - h; ctx.beginPath(); ctx.moveTo(Math.floor(peakX - w * 0.1), Math.floor(peakY + h * 0.18)); ctx.lineTo(Math.floor(peakX), Math.floor(peakY)); ctx.lineTo(Math.floor(peakX + w * 0.1), Math.floor(peakY + h * 0.18)); ctx.closePath(); ctx.fill(); } } function drawTree(x, baseY, h) { const trunkW = 3, trunkH = Math.floor(h * 0.3); ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(x - 1), Math.floor(baseY - trunkH), trunkW, trunkH); for (let i = 0; i < 3; i++) { const layerW = (h * 0.4) * (1 - i * 0.15); const layerY = baseY - trunkH - (i * h * 0.2); ctx.fillStyle = i === 1 ? '#00AA00' : '#005500'; ctx.beginPath(); ctx.moveTo(Math.floor(x - layerW / 2), Math.floor(layerY)); ctx.lineTo(Math.floor(x + 0.5), Math.floor(layerY - h * 0.25)); ctx.lineTo(Math.floor(x + layerW / 2), Math.floor(layerY)); ctx.closePath(); ctx.fill(); } } function drawWagon(wx, wy) { // Oxen (two brown lumps) ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(wx - 24), Math.floor(wy - 6), 8, 6); ctx.fillRect(Math.floor(wx - 14), Math.floor(wy - 6), 8, 6); // Oxen legs ctx.fillStyle = '#553300'; ctx.fillRect(Math.floor(wx - 22), Math.floor(wy), 2, 4); ctx.fillRect(Math.floor(wx - 18), Math.floor(wy), 2, 4); ctx.fillRect(Math.floor(wx - 12), Math.floor(wy), 2, 4); ctx.fillRect(Math.floor(wx - 8), Math.floor(wy), 2, 4); // Oxen heads ctx.fillStyle = '#885500'; ctx.fillRect(Math.floor(wx - 26), Math.floor(wy - 7), 3, 3); ctx.fillRect(Math.floor(wx - 16), Math.floor(wy - 7), 3, 3); // Yoke ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(wx - 4), Math.floor(wy - 4), 6, 2); // Wagon body ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(wx + 2), Math.floor(wy - 8), 24, 10); // Wagon sides darker ctx.fillStyle = '#885500'; ctx.fillRect(Math.floor(wx + 2), Math.floor(wy - 8), 24, 1); ctx.fillRect(Math.floor(wx + 2), Math.floor(wy + 1), 24, 1); // Canvas cover (white curved top) ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.moveTo(Math.floor(wx + 3), Math.floor(wy - 8)); ctx.quadraticCurveTo(Math.floor(wx + 14), Math.floor(wy - 22), Math.floor(wx + 25), Math.floor(wy - 8)); ctx.closePath(); ctx.fill(); // Canvas cover outline ctx.strokeStyle = '#AAAAAA'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.floor(wx + 3), Math.floor(wy - 8)); ctx.quadraticCurveTo(Math.floor(wx + 14), Math.floor(wy - 22), Math.floor(wx + 25), Math.floor(wy - 8)); ctx.stroke(); // Canvas ribs ctx.strokeStyle = '#AAAAAA'; for (let ri = 0; ri < 3; ri++) { const rx = wx + 8 + ri * 5; ctx.beginPath(); ctx.moveTo(Math.floor(rx), Math.floor(wy - 8)); ctx.lineTo(Math.floor(rx), Math.floor(wy - 14 - ri % 2 * 2)); ctx.stroke(); } // Wheels const wheelR = 5; for (let wi = 0; wi < 2; wi++) { const wcx = wx + 7 + wi * 14; const wcy = wy + 4; // Outer rim ctx.fillStyle = '#553300'; ctx.beginPath(); ctx.arc(Math.floor(wcx), Math.floor(wcy), wheelR, 0, Math.PI * 2); ctx.fill(); // Inner ctx.fillStyle = '#AA5500'; ctx.beginPath(); ctx.arc(Math.floor(wcx), Math.floor(wcy), wheelR - 1.5, 0, Math.PI * 2); ctx.fill(); // Spokes ctx.strokeStyle = '#553300'; ctx.lineWidth = 1; for (let si = 0; si < 6; si++) { const angle = wagonWheelAngle + (si * Math.PI / 3); ctx.beginPath(); ctx.moveTo(Math.floor(wcx), Math.floor(wcy)); ctx.lineTo(Math.floor(wcx + Math.cos(angle) * (wheelR - 1)), Math.floor(wcy + Math.sin(angle) * (wheelR - 1))); ctx.stroke(); } // Hub ctx.fillStyle = '#553300'; ctx.fillRect(Math.floor(wcx - 1), Math.floor(wcy - 1), 2, 2); } // Red flag (security/hacker theme) ctx.fillStyle = '#FF0000'; ctx.fillRect(Math.floor(wx + 14), Math.floor(wy - 22), 1, -7); ctx.fillStyle = '#FF5555'; ctx.fillRect(Math.floor(wx + 15), Math.floor(wy - 29), 6, 4); } function drawLandmark(type, x, baseY) { switch (type) { case 'town': ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(x), Math.floor(baseY - 18), 14, 18); ctx.fillRect(Math.floor(x + 18), Math.floor(baseY - 14), 12, 14); // Roofs ctx.fillStyle = '#555555'; ctx.beginPath(); ctx.moveTo(Math.floor(x - 2), Math.floor(baseY - 18)); ctx.lineTo(Math.floor(x + 7), Math.floor(baseY - 25)); ctx.lineTo(Math.floor(x + 16), Math.floor(baseY - 18)); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(Math.floor(x + 16), Math.floor(baseY - 14)); ctx.lineTo(Math.floor(x + 24), Math.floor(baseY - 20)); ctx.lineTo(Math.floor(x + 32), Math.floor(baseY - 14)); ctx.closePath(); ctx.fill(); // Windows ctx.fillStyle = '#FFFF55'; ctx.fillRect(Math.floor(x + 3), Math.floor(baseY - 13), 3, 3); ctx.fillRect(Math.floor(x + 9), Math.floor(baseY - 13), 3, 3); ctx.fillRect(Math.floor(x + 21), Math.floor(baseY - 10), 3, 3); // Door ctx.fillStyle = '#553300'; ctx.fillRect(Math.floor(x + 5), Math.floor(baseY - 7), 4, 7); break; case 'camp': // Tent ctx.fillStyle = '#AAAAAA'; ctx.beginPath(); ctx.moveTo(Math.floor(x - 6), Math.floor(baseY)); ctx.lineTo(Math.floor(x + 4), Math.floor(baseY - 14)); ctx.lineTo(Math.floor(x + 14), Math.floor(baseY)); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#555555'; ctx.beginPath(); ctx.moveTo(Math.floor(x + 4), Math.floor(baseY)); ctx.lineTo(Math.floor(x + 4), Math.floor(baseY - 14)); ctx.lineTo(Math.floor(x + 14), Math.floor(baseY)); ctx.closePath(); ctx.fill(); // Campfire ctx.fillStyle = '#FF5555'; ctx.fillRect(Math.floor(x + 20), Math.floor(baseY - 5), 4, 3); ctx.fillStyle = '#FFFF55'; ctx.fillRect(Math.floor(x + 21), Math.floor(baseY - 8), 2, 3); ctx.fillStyle = '#FFAA00'; ctx.fillRect(Math.floor(x + 18), Math.floor(baseY - 2), 8, 2); break; case 'mountain': drawMountain(x - 20, 60, 50, '#AA00AA', true); drawMountain(x + 10, 45, 40, '#FF55FF', true); break; case 'river': ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(x), Math.floor(baseY - 20), 3, 20); ctx.fillRect(Math.floor(x + 24), Math.floor(baseY - 20), 3, 20); ctx.strokeStyle = '#AAAAAA'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.floor(x + 1), Math.floor(baseY - 18)); ctx.lineTo(Math.floor(x + 25), Math.floor(baseY - 18)); ctx.stroke(); // Sign ctx.fillStyle = '#AA5500'; ctx.fillRect(Math.floor(x + 8), Math.floor(baseY - 25), 12, 8); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(Math.floor(x + 10), Math.floor(baseY - 23), 2, 1); ctx.fillRect(Math.floor(x + 13), Math.floor(baseY - 23), 2, 1); ctx.fillRect(Math.floor(x + 16), Math.floor(baseY - 23), 2, 1); break; case 'forest': for (let i = 0; i < 6; i++) { drawTree(x + i * 7, baseY, 14 + (i % 3) * 3); } break; } } function drawRiver(time) { const riverY = 155; const riverH = 18; ctx.fillStyle = '#0000AA'; ctx.fillRect(0, riverY, W, riverH); ctx.fillStyle = '#00AAAA'; for (let wx = 0; wx < W; wx += 3) { const waveY = riverY + 3 + Math.sin((wx + time * 0.05) * 0.08) * 3; ctx.fillRect(wx, Math.floor(waveY), 2, 1); const waveY2 = riverY + 10 + Math.sin((wx + time * 0.03 + 50) * 0.1) * 2; ctx.fillRect(wx, Math.floor(waveY2), 2, 1); } // White foam ctx.fillStyle = '#55FFFF'; for (let wx = 0; wx < W; wx += 12) { const foamY = riverY + 1 + Math.sin((wx + time * 0.04) * 0.06) * 1; ctx.fillRect(wx + 2, Math.floor(foamY), 3, 1); } } // ===================================================================== // EVENT CANVAS OVERLAYS — unique visuals per quiz event // ===================================================================== function drawEventOverlay(time) { if (!currentEventType && !currentEventTitle) return; var t = (time || 0) * 0.001; // seconds // --- SCOPE STORM: dark sky, lightning, rain --- if (currentEventTitle.indexOf('Scope Storm') !== -1) { // Darken sky ctx.fillStyle = 'rgba(0,0,50,0.45)'; ctx.fillRect(0, 0, W, 110); // Lightning flash (periodic) var flashPhase = Math.sin(t * 1.7) + Math.sin(t * 3.1); if (flashPhase > 1.6) { ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillRect(0, 0, W, H); // Lightning bolt ctx.strokeStyle = '#FFFF55'; ctx.lineWidth = 2; ctx.beginPath(); var lx = 80 + Math.sin(t * 2.3) * 60; ctx.moveTo(lx, 10); ctx.lineTo(lx - 5, 35); ctx.lineTo(lx + 8, 35); ctx.lineTo(lx - 3, 65); ctx.lineTo(lx + 10, 65); ctx.lineTo(lx + 2, 95); ctx.stroke(); } // Rain ctx.strokeStyle = '#5555FF'; ctx.lineWidth = 1; for (var i = 0; i < 40; i++) { var rx = (i * 8.3 + t * 80) % W; var ry = (i * 13.7 + t * 120) % 110; ctx.beginPath(); ctx.moveTo(Math.floor(rx), Math.floor(ry)); ctx.lineTo(Math.floor(rx - 1), Math.floor(ry + 5)); ctx.stroke(); } return; } // --- SECURITY SCOUT: person standing by campfire --- if (currentEventTitle.indexOf('Security Scout') !== -1) { var px = 200, py = 142; // Person body ctx.fillStyle = '#AA5500'; ctx.fillRect(px, py - 12, 4, 8); // torso ctx.fillStyle = '#FFAA00'; ctx.fillRect(px, py - 15, 4, 3); // head ctx.fillRect(px - 1, py - 4, 2, 5); // left leg ctx.fillRect(px + 3, py - 4, 2, 5); // right leg // Hat ctx.fillStyle = '#555555'; ctx.fillRect(px - 1, py - 17, 6, 2); ctx.fillRect(px, py - 19, 4, 2); // Campfire var fx = 212, fy = 148; ctx.fillStyle = '#AA5500'; ctx.fillRect(fx - 3, fy, 2, 3); ctx.fillRect(fx + 3, fy, 2, 3); ctx.fillStyle = '#FF5555'; var flicker = Math.sin(t * 8) * 2; ctx.fillRect(fx - 1, fy - 3 + Math.floor(flicker * 0.3), 4, 3); ctx.fillStyle = '#FFFF55'; ctx.fillRect(fx, fy - 5 + Math.floor(flicker * 0.5), 2, 2); ctx.fillStyle = '#FFAA00'; ctx.fillRect(fx - 1, fy - 2, 4, 2); // Firelight glow (subtle) ctx.fillStyle = 'rgba(255,170,0,0.08)'; ctx.beginPath(); ctx.arc(fx + 1, fy - 2, 15, 0, Math.PI * 2); ctx.fill(); return; } // --- VULNERABILITY RIVER: rough rapids with white water --- if (currentEventTitle.indexOf('Vulnerability River') !== -1) { // Extra turbulence on the river ctx.fillStyle = '#55FFFF'; for (var i = 0; i < 20; i++) { var sx = (i * 17 + t * 40) % W; var sy = 157 + Math.sin((i * 3 + t * 5)) * 3; ctx.fillRect(Math.floor(sx), Math.floor(sy), 4, 2); } // Floating logs / debris ctx.fillStyle = '#AA5500'; for (var i = 0; i < 3; i++) { var logX = (i * 110 + t * 25) % W; var logY = 159 + Math.sin(t * 2 + i) * 2; ctx.fillRect(Math.floor(logX), Math.floor(logY), 8, 2); } return; } // --- INJECTION STORM: dark sky, red-tinged rain of payloads --- if (currentEventTitle.indexOf('Injection Storm') !== -1) { // Darken sky with red tint ctx.fillStyle = 'rgba(50,0,0,0.35)'; ctx.fillRect(0, 0, W, 110); // Lightning flash (periodic) var flashPhase = Math.sin(t * 2.1) + Math.sin(t * 2.7); if (flashPhase > 1.6) { ctx.fillStyle = 'rgba(255,100,100,0.3)'; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = '#FF5555'; ctx.lineWidth = 2; ctx.beginPath(); var lx = 120 + Math.sin(t * 1.8) * 80; ctx.moveTo(lx, 10); ctx.lineTo(lx - 8, 40); ctx.lineTo(lx + 5, 40); ctx.lineTo(lx - 2, 70); ctx.stroke(); } // Red-tinted rain (payloads) ctx.strokeStyle = '#FF5555'; ctx.lineWidth = 1; for (var i = 0; i < 35; i++) { var rx = (i * 9.1 + t * 70) % W; var ry = (i * 12.3 + t * 100) % 110; ctx.beginPath(); ctx.moveTo(Math.floor(rx), Math.floor(ry)); ctx.lineTo(Math.floor(rx - 1), Math.floor(ry + 4)); ctx.stroke(); } return; } // --- AUTH BYPASS GONE WRONG: red sky, smoke, warning --- if (currentEventTitle.indexOf('Auth Bypass Gone Wrong') !== -1) { // Red danger tint ctx.fillStyle = 'rgba(170,0,0,0.25)'; ctx.fillRect(0, 0, W, H); // Smoke particles rising ctx.fillStyle = '#555555'; for (var i = 0; i < 15; i++) { var sx = 140 + Math.sin(i * 2.7 + t) * 30; var sy = 130 - ((t * 20 + i * 15) % 90); var size = 2 + (i % 3); ctx.globalAlpha = 0.3 + Math.sin(t + i) * 0.15; ctx.fillRect(Math.floor(sx), Math.floor(sy), size, size); } ctx.globalAlpha = 1.0; // Fire at base ctx.fillStyle = '#FF5555'; for (var i = 0; i < 8; i++) { var fx = 130 + i * 5; var fh = 4 + Math.sin(t * 6 + i * 1.5) * 3; ctx.fillRect(fx, Math.floor(140 - fh), 3, Math.floor(fh)); } ctx.fillStyle = '#FFFF55'; for (var i = 0; i < 5; i++) { var fx = 135 + i * 6; var fh = 2 + Math.sin(t * 8 + i * 2) * 2; ctx.fillRect(fx, Math.floor(139 - fh), 2, Math.floor(fh)); } // Pulsing red border var pulse = (Math.sin(t * 4) + 1) * 0.15; ctx.strokeStyle = 'rgba(255,0,0,' + pulse.toFixed(2) + ')'; ctx.lineWidth = 3; ctx.strokeRect(1, 1, W - 2, H - 2); ctx.lineWidth = 1; return; } // --- PROOF-BY-EXPLOITATION PIONEER: person at checkpoint --- if (currentEventTitle.indexOf('Proof-by-Exploitation') !== -1) { var px = 200, py = 142; // Person body ctx.fillStyle = '#AA5500'; ctx.fillRect(px, py - 12, 4, 8); ctx.fillStyle = '#FFAA00'; ctx.fillRect(px, py - 15, 4, 3); ctx.fillRect(px - 1, py - 4, 2, 5); ctx.fillRect(px + 3, py - 4, 2, 5); // Hat ctx.fillStyle = '#555555'; ctx.fillRect(px - 1, py - 17, 6, 2); ctx.fillRect(px, py - 19, 4, 2); // Terminal/laptop nearby ctx.fillStyle = '#333333'; ctx.fillRect(px + 10, py - 8, 10, 7); ctx.fillStyle = '#55FF55'; ctx.fillRect(px + 11, py - 7, 8, 5); // Blinking cursor on screen if (Math.sin(t * 4) > 0) { ctx.fillStyle = '#55FF55'; ctx.fillRect(px + 13, py - 5, 2, 1); } return; } } // ===================================================================== // DEATH SCREEN — Apple II green phosphor style // ===================================================================== function drawDeathScene(time) { var t = (time || 0) * 0.001; var G = '#33FF33'; // Apple II green var GD = '#1a8a1a'; // darker green for details // Black background ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, W, H); // Ground line ctx.fillStyle = GD; ctx.fillRect(0, 148, W, 1); // --- OX (left side) --- var ox = 100, oy = 128; // Body ctx.fillStyle = G; ctx.fillRect(ox, oy, 24, 14); // Head ctx.fillRect(ox - 10, oy - 2, 12, 10); // Horns ctx.fillRect(ox - 12, oy - 6, 3, 5); ctx.fillRect(ox - 4, oy - 6, 3, 5); // Snout ctx.fillStyle = GD; ctx.fillRect(ox - 10, oy + 4, 6, 3); // Eye ctx.fillStyle = '#000'; ctx.fillRect(ox - 7, oy, 2, 2); // Legs ctx.fillStyle = G; ctx.fillRect(ox + 2, oy + 14, 4, 8); ctx.fillRect(ox + 10, oy + 14, 4, 8); ctx.fillRect(ox + 16, oy + 14, 4, 8); // Tail ctx.fillRect(ox + 24, oy + 2, 6, 2); ctx.fillRect(ox + 29, oy, 2, 4); // --- HITCH --- ctx.fillStyle = G; ctx.fillRect(ox + 28, oy + 8, 14, 2); // --- WAGON --- var wx = 142, wy = 108; // Wagon bed ctx.fillStyle = G; ctx.fillRect(wx, wy + 20, 50, 16); // Bed slats (dark lines) ctx.fillStyle = GD; ctx.fillRect(wx, wy + 24, 50, 1); ctx.fillRect(wx, wy + 28, 50, 1); ctx.fillRect(wx, wy + 32, 50, 1); // Side rails ctx.fillStyle = G; ctx.fillRect(wx, wy + 18, 2, 18); ctx.fillRect(wx + 48, wy + 18, 2, 18); // Canvas cover (the bonnet) ctx.fillStyle = G; // Left arch ctx.fillRect(wx + 4, wy + 4, 2, 16); // Right arch ctx.fillRect(wx + 44, wy + 4, 2, 16); // Top cover ctx.fillRect(wx + 4, wy + 2, 42, 4); // Cover fill ctx.fillRect(wx + 6, wy + 6, 38, 12); // Cover shading ctx.fillStyle = GD; ctx.fillRect(wx + 8, wy + 8, 34, 2); ctx.fillRect(wx + 8, wy + 12, 34, 2); ctx.fillRect(wx + 8, wy + 16, 34, 1); // Wheels var wheelR = 8; var wheels = [wx + 8, wx + 42]; var wa = t * 0.5; // slow rotation ctx.strokeStyle = G; ctx.lineWidth = 1.5; for (var wi = 0; wi < wheels.length; wi++) { var cx = wheels[wi], cy = wy + 38; // Rim ctx.beginPath(); ctx.arc(cx, cy, wheelR, 0, Math.PI * 2); ctx.stroke(); // Spokes for (var sp = 0; sp < 6; sp++) { var angle = wa + sp * Math.PI / 3; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(angle) * wheelR, cy + Math.sin(angle) * wheelR); ctx.stroke(); } // Hub ctx.fillStyle = G; ctx.beginPath(); ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.fill(); } ctx.lineWidth = 1; // Subtle scanline effect ctx.fillStyle = 'rgba(0,0,0,0.08)'; for (var sl = 0; sl < H; sl += 3) { ctx.fillRect(0, sl, W, 1); } // Subtle green glow/vignette var grd = ctx.createRadialGradient(W/2, H/2, 40, W/2, H/2, W * 0.7); grd.addColorStop(0, 'rgba(0,0,0,0)'); grd.addColorStop(1, 'rgba(0,0,0,0.4)'); ctx.fillStyle = grd; ctx.fillRect(0, 0, W, H); } function renderCanvas(time) { // Death screen: green phosphor Apple II style if (gameState === STATES.DEATH) { drawDeathScene(time); return; } const skyColors = getSkyColors(); // Sky gradient (banded for EGA feel) const bands = 10; const skyBottom = 110; for (let i = 0; i < bands; i++) { const t = i / (bands - 1); const r1 = parseInt(skyColors.top.substr(1, 2), 16); const g1 = parseInt(skyColors.top.substr(3, 2), 16); const b1 = parseInt(skyColors.top.substr(5, 2), 16); const r2 = parseInt(skyColors.bottom.substr(1, 2), 16); const g2 = parseInt(skyColors.bottom.substr(3, 2), 16); const b2 = parseInt(skyColors.bottom.substr(5, 2), 16); const r = Math.floor(r1 + (r2 - r1) * t); const g = Math.floor(g1 + (g2 - g1) * t); const b = Math.floor(b1 + (b2 - b1) * t); ctx.fillStyle = `rgb(${r},${g},${b})`; const bandH = Math.ceil(skyBottom / bands) + 1; ctx.fillRect(0, Math.floor(i * (skyBottom / bands)), W, bandH); } // Clouds (parallax 0.2x) const cloudOffset = scrollX * 0.2; for (const c of cloudPositions) { let cx = ((c.x - cloudOffset) % (W + 80)); if (cx < -40) cx += W + 80; drawPixelCloud(cx - 20, c.y, c.w, c.h); } // Far mountains (parallax 0.3x) - DARK MAGENTA signature Oregon Trail const farOffset = scrollX * 0.3; for (const m of farMountains) { let mx = ((m.x - farOffset) % (W + 100)); if (mx < -50) mx += W + 100; drawMountain(mx - 25, m.h, m.w, '#AA00AA', false); } // Near mountains (parallax 0.4x) - LIGHT MAGENTA with snow caps const nearOffset = scrollX * 0.4; for (const m of nearMountains) { let mx = ((m.x - nearOffset) % (W + 80)); if (mx < -40) mx += W + 80; drawMountain(mx - 20, m.h, m.w, '#FF55FF', m.snow); } // Grass ctx.fillStyle = '#00AA00'; ctx.fillRect(0, 110, W, H - 110); // Bright grass highlights ctx.fillStyle = '#55FF55'; const grassOffset = scrollX * 1.0; for (let i = 0; i < 50; i++) { let gx = ((i * 11 + 5 - grassOffset) % (W + 20)); if (gx < -5) gx += W + 20; const gy = 113 + (i * 7 + i * i * 3) % 55; ctx.fillRect(Math.floor(gx), Math.floor(gy), 2, 2); } // Trees (parallax 0.6x) const treeOffset = scrollX * 0.6; const treeBaseY = 128; for (const t of treesData) { let tx = ((t.x - treeOffset) % (W + 60)); if (tx < -20) tx += W + 60; drawTree(tx - 10, treeBaseY, t.h); } // Landmark for current stop (only at stops/events) if (gameState === STATES.STOP || gameState === STATES.EVENT || gameState === STATES.RIVER) { const stop = TRAIL_DATA.stops[currentStop]; if (stop) { drawLandmark(stop.landmarkType, 240, 150); } } // Trail/ground strip if (!showRiver) { ctx.fillStyle = '#AA5500'; ctx.fillRect(0, 152, W, 15); // Trail texture dots ctx.fillStyle = '#885500'; for (let i = 0; i < 35; i++) { let dotX = ((i * 10 - scrollX * 1.0) % (W + 10)); if (dotX < -3) dotX += W + 10; ctx.fillRect(Math.floor(dotX), 155 + (i % 4) * 2, 3, 1); } // Ruts ctx.fillStyle = '#775500'; ctx.fillRect(0, 157, W, 1); ctx.fillRect(0, 162, W, 1); } // River (when active) if (showRiver) { drawRiver(time || 0); } // Wagon const wagonX = 65; const wagonY = showRiver ? 148 : 149; drawWagon(wagonX, wagonY); // Event-specific visual overlay if (gameState === STATES.EVENT || gameState === STATES.RIVER) { drawEventOverlay(time); } // Ground below trail ctx.fillStyle = '#885500'; ctx.fillRect(0, showRiver ? 173 : 167, W, 15); ctx.fillStyle = '#553300'; ctx.fillRect(0, showRiver ? 188 : 182, W, H - 182); } // ===================================================================== // MUSIC SYSTEM (Web Audio API Chiptune) // ===================================================================== const NOTE_FREQ = { 'C3': 130.81, 'D3': 146.83, 'G2': 98.00, 'A2': 110.00, 'C4': 261.63, 'D4': 293.66, 'E4': 329.63, 'G4': 392.00, 'A4': 440.00, 'C5': 523.25, 'REST': 0 }; const melody = [ { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'A4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'C4', dur: 1 }, { note: 'REST', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'A4', dur: 1 }, { note: 'C5', dur: 1 }, { note: 'A4', dur: 2 }, { note: 'G4', dur: 2 }, { note: 'C4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'A4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'C4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'C4', dur: 3 } ]; const bassLine = [ { note: 'C3', dur: 4 }, { note: 'A2', dur: 4 }, { note: 'C3', dur: 4 }, { note: 'A2', dur: 4 }, { note: 'C3', dur: 4 }, { note: 'G2', dur: 4 }, { note: 'D3', dur: 4 }, { note: 'C3', dur: 4 } ]; let musicScheduleTimer = null; let nextMelodyTime = 0; let nextBassTime = 0; var masterGain = null; function initAudio() { if (audioCtx) return; try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); masterGain = audioCtx.createGain(); masterGain.gain.value = 1.0; masterGain.connect(audioCtx.destination); musicInitialized = true; } catch (e) { console.warn('Web Audio not available:', e); } } function playNote(freq, startTime, duration, type, gainVal) { if (!audioCtx || freq === 0) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, startTime); // Envelope: attack then decay gain.gain.setValueAtTime(0.001, startTime); gain.gain.linearRampToValueAtTime(gainVal, startTime + 0.02); gain.gain.setValueAtTime(gainVal, startTime + duration * 0.75); gain.gain.linearRampToValueAtTime(0.001, startTime + duration * 0.95); osc.connect(gain); gain.connect(masterGain || audioCtx.destination); osc.start(startTime); osc.stop(startTime + duration); } function scheduleLoop() { if (!musicPlaying || !audioCtx) return; const bpm = 110; const quarterDur = 60 / bpm; const now = audioCtx.currentTime; // Schedule melody let t = now + 0.05; for (const note of melody) { const dur = note.dur * quarterDur; if (note.note !== 'REST') { playNote(NOTE_FREQ[note.note], t, dur, 'square', 0.06); } t += dur; } // Schedule bass let tb = now + 0.05; for (const note of bassLine) { const dur = note.dur * quarterDur; playNote(NOTE_FREQ[note.note], tb, dur, 'triangle', 0.08); tb += dur; } // Total loop duration in beats: melody has 32 quarter beats = 32 * quarterDur const totalBeats = 32; const loopDurationMs = totalBeats * quarterDur * 1000; musicScheduleTimer = setTimeout(scheduleLoop, loopDurationMs - 200); } function startMusic() { if (!musicInitialized) initAudio(); if (!audioCtx) return; if (audioCtx.state === 'suspended') audioCtx.resume(); if (masterGain) masterGain.gain.setValueAtTime(1.0, audioCtx.currentTime); musicPlaying = true; scheduleLoop(); updateMusicIndicator(); } function stopMusic() { musicPlaying = false; if (musicScheduleTimer) { clearTimeout(musicScheduleTimer); musicScheduleTimer = null; } if (masterGain) masterGain.gain.setValueAtTime(0, audioCtx.currentTime); updateMusicIndicator(); } function toggleMusic() { if (!musicInitialized) initAudio(); if (musicPlaying) { stopMusic(); } else { startMusic(); } renderStatusBar(); } function updateMusicIndicator() { const el = document.getElementById('music-indicator'); if (!el) return; el.textContent = musicPlaying ? 'M: Music ON' : 'M: Music OFF'; el.style.color = musicPlaying ? '#55FF55' : '#555555'; } // ===================================================================== // SYNTAX HIGHLIGHTING (EGA Colors) // ===================================================================== const TS_KEYWORDS = new Set([ 'import', 'export', 'from', 'const', 'let', 'var', 'function', 'async', 'await', 'return', 'if', 'else', 'for', 'while', 'new', 'typeof', 'instanceof', 'interface', 'type', 'class', 'extends', 'implements', 'try', 'catch', 'throw', 'default', 'switch', 'case', 'break', 'continue', 'true', 'false', 'null', 'undefined', 'void', 'of', 'in' ]); function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function highlightCode(code) { return code.split('\n').map(function(line) { if (line.trim().startsWith('//')) { return '' + escHtml(line) + ''; } var result = ''; var i = 0; while (i < line.length) { // String literal if (line[i] === "'" || line[i] === '"' || line[i] === '`') { var quote = line[i]; var end = i + 1; while (end < line.length && line[end] !== quote) { if (line[end] === '\\') end++; end++; } end = Math.min(end + 1, line.length); result += '' + escHtml(line.substring(i, end)) + ''; i = end; continue; } // Inline comment if (line[i] === '/' && i + 1 < line.length && line[i + 1] === '/') { result += '' + escHtml(line.substring(i)) + ''; break; } // Number if (/\d/.test(line[i]) && (i === 0 || !/\w/.test(line[i - 1]))) { var end2 = i; while (end2 < line.length && /[\d._]/.test(line[end2])) end2++; result += '' + escHtml(line.substring(i, end2)) + ''; i = end2; continue; } // Identifier or keyword if (/[a-zA-Z_$]/.test(line[i])) { var end3 = i; while (end3 < line.length && /[\w$]/.test(line[end3])) end3++; var word = line.substring(i, end3); if (TS_KEYWORDS.has(word)) { result += '' + escHtml(word) + ''; } else { result += '' + escHtml(word) + ''; } i = end3; continue; } // Punctuation / operators result += '' + escHtml(line[i]) + ''; i++; } return result; }).join('\n'); } // ===================================================================== // TEXT PANEL RENDERING // ===================================================================== var textPanel = document.getElementById('text-panel'); var statusBar = document.getElementById('status-bar'); function setTextPanel(html) { textPanel.innerHTML = html; textPanel.scrollTop = 0; } function renderTitleScreen() { setTextPanel( '
' + '
THE CODEREGON TRAIL
' + '
The Exploit Trail - A Shannon Learning Adventure
' + '' + '
' ); } function renderSetupScreen() { var profsHtml = ''; for (var i = 0; i < PROFESSIONS.length; i++) { var p = PROFESSIONS[i]; var sel = (i === selectedDifficulty); var color = sel ? '#FFFF55' : '#AAAAAA'; var marker = sel ? '>' : ' '; var perks = 'HP:' + p.health + ' Hints:' + p.supplies; if (p.hintFree) perks += ' (free hints!)'; profsHtml += '
' + marker + ' [' + (i + 1) + '] ' + p.name + '
'; profsHtml += '
' + p.desc + ' ' + perks + '
'; } var hearts = '\u2665\u2665\u2665'; setTextPanel( '
' + '
Your party of key concepts:
' + '
' + '
1. Reconnaissance ' + hearts + '
' + '
2. Exploitation ' + hearts + '
' + '
3. Browser Automation ' + hearts + '
' + '
4. Proof-by-Exploit ' + hearts + '
' + '
' + '
What is your profession?
' + '
' + profsHtml + '
' + '' + '
' ); } function renderTravelScreen() { var flavor = travelFlavors[travelFlavorIndex % travelFlavors.length]; setTextPanel( '
' + '
Day ' + day + '...
' + '
' + escHtml(flavor) + '
' + '' + '
' ); } function renderStopScreen() { var stop = TRAIL_DATA.stops[currentStop]; if (!stop) return; var codeHtml = highlightCode(stop.code.content); setTextPanel( '
' + '
\u2550\u2550 ' + escHtml(stop.name) + ' \u2014 ' + escHtml(stop.subtitle) + ' \u2550\u2550
' + '
[' + escHtml(stop.concept) + ']
' + '
\u250C\u2500 ' + escHtml(stop.code.file) + ' \u2500\u2500\u2500
' + '
' + codeHtml + '
' + '
' + escHtml(stop.narration) + '
' + '
' + '[1] Continue' + '[2] Examine code' + '
' + '
' ); } function renderEventScreen(event) { var isRiver = event.type === 'river'; var isFortune = event.type === 'fortune'; var typeLabel, headerColor; if (isRiver) { typeLabel = '\uD83C\uDF0A RIVER CROSSING'; headerColor = '#5555FF'; } else if (event.type === 'weather') { typeLabel = '\u26A1 WEATHER'; headerColor = '#FFFF55'; } else if (event.type === 'encounter') { typeLabel = '\uD83D\uDC64 ENCOUNTER'; headerColor = '#FFFF55'; } else if (event.type === 'misfortune') { typeLabel = '\u2620 MISFORTUNE'; headerColor = '#FF5555'; } else { typeLabel = '\u2728 FORTUNE'; headerColor = '#55FF55'; } var choicesHtml = ''; if (event.choices.length > 0 && !eventAnswered) { var labels = ['A', 'B', 'C', 'D']; for (var i = 0; i < shuffledIndices.length; i++) { var origIdx = shuffledIndices[i]; var dimmedClass = (eventHinted && dimmedChoice === i) ? ' choice-dimmed' : ''; var selClass = (selectedEventChoice === i) ? ' choice-selected' : ''; choicesHtml += '
' + labels[i] + '. ' + escHtml(event.choices[origIdx].text) + '
'; } var hintAvail = (PROFESSIONS[difficulty] && PROFESSIONS[difficulty].hintFree) || supplies > 0; var hintLabel = (PROFESSIONS[difficulty] && PROFESSIONS[difficulty].hintFree) ? 'H = Free Hint' : 'H = Hint (-1 supply)'; choicesHtml += '
Arrow keys + Enter to choose' + (hintAvail ? ' | ' + hintLabel : '') + '
'; } else if (isFortune) { choicesHtml = '
+20 Health restored!
' + ''; } setTextPanel( '
' + '
' + typeLabel + ' \u2014 ' + escHtml(event.title) + '
' + '
' + escHtml(event.text) + '
' + '
' + choicesHtml + '
' + '
' + '
' ); } function showEventResult(event, displayIdx) { var origIdx = shuffledIndices[displayIdx]; var choice = event.choices[origIdx]; var correct = choice.correct; var isRiver = event.type === 'river'; var resultHtml = ''; if (correct) { var bonus = streak >= 3 ? ' (+5 streak bonus!)' : ''; resultHtml = '
CORRECT! +10 Health' + bonus + '
'; } else { var dmg = isRiver ? 20 : 15; resultHtml = '
WRONG! -' + dmg + ' Health
'; document.getElementById('game-container').classList.add('shake'); setTimeout(function() { document.getElementById('game-container').classList.remove('shake'); }, 500); } resultHtml += '
' + escHtml(choice.explanation) + '
'; resultHtml += ''; // Update choices display in shuffled order var labels = ['A', 'B', 'C', 'D']; var choicesHtml = ''; for (var i = 0; i < shuffledIndices.length; i++) { var oi = shuffledIndices[i]; var c = event.choices[oi]; var cls = c.correct ? 'choice-correct' : (i === displayIdx ? 'choice-wrong' : 'text-gray'); var marker = c.correct ? '\u2713' : (i === displayIdx ? '\u2717' : ' '); choicesHtml += '
' + marker + ' ' + labels[i] + '. ' + escHtml(c.text) + '
'; } var choicesEl = document.getElementById('event-choices'); if (choicesEl) choicesEl.innerHTML = choicesHtml; var resultEl = document.getElementById('event-result'); if (resultEl) resultEl.innerHTML = resultHtml; } function renderDeathScreen() { var deathMsg; if (techDebtDeath) { var techDebtMsgs = [ 'You have died of tech debt dysentery.', ]; deathMsg = techDebtMsgs[Math.floor(Math.random() * techDebtMsgs.length)]; techDebtDeath = false; } else { deathMsg = TRAIL_DATA.deathMessages[Math.floor(Math.random() * TRAIL_DATA.deathMessages.length)]; } setTextPanel( '
' + '
' + '' + '
' + '
Stops: ' + currentStop + '/' + TRAIL_DATA.stops.length + ' Score: ' + score + '/' + totalQuestions + ' Streak: ' + bestStreak + '
' + '' + '
' ); // Typewriter animation typewriterText = deathMsg; typewriterIndex = 0; if (typewriterTimer) clearInterval(typewriterTimer); typewriterTimer = setInterval(function() { var el = document.getElementById('death-text'); if (!el) { clearInterval(typewriterTimer); return; } typewriterIndex++; el.textContent = typewriterText.substring(0, typewriterIndex); if (typewriterIndex >= typewriterText.length) clearInterval(typewriterTimer); }, 50); } function renderWinScreen() { var survivors = partyHealth.filter(function(h) { return h > 0; }).length; var pctScore = totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 100; var conceptNames = ['Reconnaissance', 'Exploitation', 'Browser Automation', 'Proof-by-Exploit']; var masteryHtml = ''; var weakestName = ''; var weakestHp = 999; for (var i = 0; i < conceptNames.length; i++) { var hp = partyHealth[i]; var maxHp = TRAIL_DATA.partyMembers[i].maxHealth; var stars, color; if (hp <= 0) { stars = '\u2606\u2606\u2606 (died)'; color = '#555555'; } else { stars = ''; for (var s = 0; s < hp; s++) stars += '\u2605'; for (var s2 = 0; s2 < maxHp - hp; s2++) stars += '\u2606'; color = '#FFFF55'; } if (hp < weakestHp) { weakestHp = hp; weakestName = conceptNames[i]; } masteryHtml += '
' + conceptNames[i] + ' ' + stars + '
'; } var tip = weakestHp < TRAIL_DATA.partyMembers[0].maxHealth ? '"Study up on ' + weakestName + '!"' : '"Perfect understanding!"'; setTextPanel( '
' + '
\u2605 YOU MADE IT TO RESPONSE FRONTIER! \u2605
' + '
' + '
Framework: Shannon (The Exploit Trail)
' + '
Survivors: ' + survivors + '/' + partyHealth.length + '
' + '
Quiz Score: ' + score + '/' + totalQuestions + ' (' + pctScore + '%)
' + '
Best Streak: ' + bestStreak + '
' + '
Hints Used: ' + hintsUsed + '
' + '
' + '
Concept Mastery:
' + '
' + masteryHtml + '
' + '
' + tip + '
' + '' + '
' ); } // ===================================================================== // STATUS BAR // ===================================================================== function renderStatusBar() { if (gameState === STATES.TITLE) { var titleMusic = musicPlaying ? 'Music: On (M to toggle off)' : 'Music: Off (M to toggle on)'; statusBar.innerHTML = 'The Coderegon Trail v1.0 | ' + titleMusic; return; } var maxH = maxHealthForGame; var healthPct = Math.max(0, health / maxH); var barLen = 10; var filled = Math.round(healthPct * barLen); var healthColor = health > maxH * 0.6 ? '#00AA00' : (health > maxH * 0.3 ? '#FFFF55' : '#FF5555'); var healthBar = ''; for (var i = 0; i < filled; i++) healthBar += '\u2588'; healthBar += ''; for (var i2 = 0; i2 < barLen - filled; i2++) healthBar += '\u2591'; healthBar += ''; var streakStars = ''; for (var s = 0; s < Math.min(streak, 5); s++) streakStars += '\u2605'; streakStars += ''; var musicHint = musicPlaying ? 'Music: On (M to toggle off)' : 'Music: Off (M to toggle on)'; statusBar.innerHTML = 'HP: ' + healthBar + ' ' + Math.max(0, health) + ' Hints: ' + supplies + ' ' + streakStars + ' | Stop ' + (currentStop + 1) + '/' + TRAIL_DATA.stops.length + ' | ' + musicHint; } // ===================================================================== // GAME LOGIC // ===================================================================== function resetGame() { gameState = STATES.TITLE; difficulty = 1; selectedDifficulty = 1; health = 100; maxHealthForGame = 100; supplies = 5; currentStop = 0; day = 1; score = 0; totalQuestions = 0; streak = 0; bestStreak = 0; hintsUsed = 0; scrollX = 0; showRiver = false; wagonWheelAngle = 0; travelFlavorIndex = 0; deathPending = false; partyHealth = TRAIL_DATA.partyMembers.map(function(p) { return p.maxHealth; }); pendingEvents = []; currentEventIndex = 0; eventAnswered = false; eventHinted = false; dimmedChoice = -1; selectedEventChoice = 0; selectedStopChoice = 0; shuffledIndices = []; currentEventType = ''; currentEventTitle = ''; drainAccumulator = 0; techDebtDeath = false; if (travelTimer) { clearTimeout(travelTimer); travelTimer = null; } if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; } } function selectDifficulty(idx) { selectedDifficulty = Math.max(0, Math.min(PROFESSIONS.length - 1, idx)); renderSetupScreen(); } function startGame() { difficulty = selectedDifficulty; var prof = PROFESSIONS[difficulty]; health = prof.health; maxHealthForGame = prof.health; supplies = prof.supplies; partyHealth = TRAIL_DATA.partyMembers.map(function(p) { return p.maxHealth; }); currentStop = 0; day = 1; score = 0; totalQuestions = 0; streak = 0; bestStreak = 0; hintsUsed = 0; scrollX = 0; deathPending = false; enterTravel(); } function enterTravel() { gameState = STATES.TRAVEL; showRiver = false; currentEventType = ''; currentEventTitle = ''; day += Math.floor(Math.random() * 7) + 1; travelFlavorIndex = Math.floor(Math.random() * travelFlavors.length); renderTravelScreen(); renderStatusBar(); if (travelTimer) clearTimeout(travelTimer); travelTimer = setTimeout(function() { if (gameState === STATES.TRAVEL) advanceFromTravel(); }, 2500); } function advanceFromTravel() { if (travelTimer) { clearTimeout(travelTimer); travelTimer = null; } // Collect events for the current stop (triggerStop is 1-indexed relative to stop we just visited) // Events trigger BEFORE showing the stop pendingEvents = TRAIL_DATA.events.filter(function(e) { return e.triggerStop === currentStop; }); currentEventIndex = 0; if (pendingEvents.length > 0) { showNextEvent(); } else { enterStop(); } } function showNextEvent() { if (currentEventIndex >= pendingEvents.length) { // After all events, check if we should die if (checkDeath()) return; enterStop(); return; } var event = pendingEvents[currentEventIndex]; eventAnswered = false; eventHinted = false; dimmedChoice = -1; selectedEventChoice = 0; currentEventType = event.type; currentEventTitle = event.title; // Shuffle choice order so correct answer isn't always first shuffledIndices = []; for (var si = 0; si < event.choices.length; si++) shuffledIndices.push(si); for (var si = shuffledIndices.length - 1; si > 0; si--) { var sj = Math.floor(Math.random() * (si + 1)); var tmp = shuffledIndices[si]; shuffledIndices[si] = shuffledIndices[sj]; shuffledIndices[sj] = tmp; } if (event.type === 'river') { gameState = STATES.RIVER; showRiver = true; } else { gameState = STATES.EVENT; showRiver = false; } // Fortune events auto-apply health if (event.type === 'fortune') { health = Math.min(health + 20, maxHealthForGame); eventAnswered = true; } renderEventScreen(event); renderStatusBar(); } function handleEventChoice(displayIdx) { if (eventAnswered) return; var event = pendingEvents[currentEventIndex]; if (!event || event.choices.length === 0) return; if (displayIdx < 0 || displayIdx >= shuffledIndices.length) return; if (eventHinted && dimmedChoice === displayIdx) return; var prof = PROFESSIONS[difficulty]; var forgiving = prof && prof.forgiving; var origIdx = shuffledIndices[displayIdx]; var choice = event.choices[origIdx]; var isRiver = event.type === 'river'; if (forgiving && !choice.correct) { // Ralph Wiggum mode: dim the wrong answer, let them try again var items = document.querySelectorAll('.choice-item'); if (items[displayIdx]) { items[displayIdx].classList.add('choice-dimmed'); items[displayIdx].style.pointerEvents = 'none'; } // Move selection to next available choice for (var ni = 0; ni < shuffledIndices.length; ni++) { var nextIdx = (displayIdx + 1 + ni) % shuffledIndices.length; if (!(eventHinted && dimmedChoice === nextIdx) && !items[nextIdx].classList.contains('choice-dimmed')) { selectedEventChoice = nextIdx; updateChoiceHighlight(); break; } } return; } eventAnswered = true; totalQuestions++; if (choice.correct) { var bonus = streak >= 3 ? 5 : 0; health = Math.min(health + 10 + bonus, maxHealthForGame); score++; streak++; if (streak > bestStreak) bestStreak = streak; } else { var dmg = isRiver ? 20 : 15; health -= dmg; streak = 0; // Damage related party member var concept = event.concept; var memberIdx = -1; for (var mi = 0; mi < TRAIL_DATA.partyMembers.length; mi++) { if (TRAIL_DATA.partyMembers[mi].name === concept) { memberIdx = mi; break; } } if (memberIdx >= 0 && partyHealth[memberIdx] > 0) { partyHealth[memberIdx]--; } } showEventResult(event, displayIdx); renderStatusBar(); } function handleEventHint() { var prof = PROFESSIONS[difficulty]; var hintFree = prof && prof.hintFree; if (eventAnswered || eventHinted) return; if (!hintFree && supplies <= 0) return; var event = pendingEvents[currentEventIndex]; if (!event || event.choices.length === 0) return; if (!hintFree) supplies--; hintsUsed++; eventHinted = true; // Find a wrong answer in display order (dimmedChoice is a display index) var wrongDisplayIndices = []; for (var i = 0; i < shuffledIndices.length; i++) { if (!event.choices[shuffledIndices[i]].correct) wrongDisplayIndices.push(i); } dimmedChoice = wrongDisplayIndices[Math.floor(Math.random() * wrongDisplayIndices.length)]; renderEventScreen(event); renderStatusBar(); } function updateChoiceHighlight() { var items = document.querySelectorAll('.choice-item'); for (var i = 0; i < items.length; i++) { items[i].classList.toggle('choice-selected', i === selectedEventChoice); } } function updateStopHighlight() { var items = document.querySelectorAll('.stop-opt'); for (var i = 0; i < items.length; i++) { items[i].classList.toggle('choice-selected', i === selectedStopChoice); } } function checkDeath() { if (health <= 0) { enterDeath(); return true; } var allDead = true; for (var i = 0; i < partyHealth.length; i++) { if (partyHealth[i] > 0) { allDead = false; break; } } if (allDead) { enterDeath(); return true; } return false; } function advanceFromEvent() { currentEventIndex++; if (currentEventIndex < pendingEvents.length) { if (checkDeath()) return; showNextEvent(); } else { if (checkDeath()) return; enterStop(); } } function enterStop() { gameState = STATES.STOP; showRiver = false; selectedStopChoice = 0; var stop = TRAIL_DATA.stops[currentStop]; if (stop && stop.landmarkType === 'river') { showRiver = true; } renderStopScreen(); renderStatusBar(); } function handleStopChoice(choice) { if (choice === 2) { var codeBox = textPanel.querySelector('.code-box'); if (codeBox) { codeBox.style.maxHeight = '280px'; codeBox.scrollIntoView({ behavior: 'smooth' }); } return; } currentStop++; if (currentStop >= TRAIL_DATA.stops.length) { enterWin(); } else { enterTravel(); } } function enterDeath() { gameState = STATES.DEATH; showRiver = false; renderDeathScreen(); renderStatusBar(); } function enterWin() { gameState = STATES.WIN; showRiver = false; renderWinScreen(); renderStatusBar(); } // ===================================================================== // INPUT HANDLING // ===================================================================== function ensureAudio() { if (!musicInitialized) { initAudio(); startMusic(); } } document.addEventListener('keydown', function(e) { var key = e.key; // Music toggle always works if (key === 'm' || key === 'M') { if (!musicInitialized) initAudio(); toggleMusic(); return; } // Start audio on first interaction if (!musicInitialized) ensureAudio(); switch (gameState) { case STATES.TITLE: if (key === ' ' || key === 'Enter') { e.preventDefault(); gameState = STATES.SETUP; renderSetupScreen(); renderStatusBar(); } break; case STATES.SETUP: if (key === 'ArrowDown' || key === 'ArrowRight') { e.preventDefault(); selectDifficulty((selectedDifficulty + 1) % PROFESSIONS.length); } else if (key === 'ArrowUp' || key === 'ArrowLeft') { e.preventDefault(); selectDifficulty((selectedDifficulty - 1 + PROFESSIONS.length) % PROFESSIONS.length); } else if (key === '1') selectDifficulty(0); else if (key === '2') selectDifficulty(1); else if (key === '3') selectDifficulty(2); else if (key === '4') selectDifficulty(3); else if (key === 'Enter' || key === ' ') { e.preventDefault(); startGame(); } break; case STATES.TRAVEL: if (key === ' ' || key === 'Enter') { e.preventDefault(); advanceFromTravel(); } break; case STATES.STOP: if (key === 'ArrowLeft' || key === 'ArrowRight') { e.preventDefault(); selectedStopChoice = (key === 'ArrowLeft') ? 0 : 1; updateStopHighlight(); } else if (key === 'ArrowUp' || key === 'ArrowDown') { e.preventDefault(); selectedStopChoice = selectedStopChoice === 0 ? 1 : 0; updateStopHighlight(); } else if (key === 'Enter' || key === ' ') { e.preventDefault(); handleStopChoice(selectedStopChoice + 1); } else if (key === '1') { handleStopChoice(1); } else if (key === '2') { handleStopChoice(2); } break; case STATES.EVENT: case STATES.RIVER: if (!eventAnswered) { var ev = pendingEvents[currentEventIndex]; if (ev && ev.choices.length > 0) { var numChoices = ev.choices.length; if (key === 'ArrowDown' || key === 'ArrowRight') { e.preventDefault(); selectedEventChoice = (selectedEventChoice + 1) % numChoices; // Skip dimmed choice if (eventHinted && dimmedChoice === selectedEventChoice) selectedEventChoice = (selectedEventChoice + 1) % numChoices; updateChoiceHighlight(); } else if (key === 'ArrowUp' || key === 'ArrowLeft') { e.preventDefault(); selectedEventChoice = (selectedEventChoice - 1 + numChoices) % numChoices; if (eventHinted && dimmedChoice === selectedEventChoice) selectedEventChoice = (selectedEventChoice - 1 + numChoices) % numChoices; updateChoiceHighlight(); } else if (key === 'Enter' || key === ' ') { e.preventDefault(); if (!(eventHinted && dimmedChoice === selectedEventChoice)) { handleEventChoice(selectedEventChoice); } } else if (key === '1' || key === 'a' || key === 'A') handleEventChoice(0); else if (key === '2' || key === 'b' || key === 'B') handleEventChoice(1); else if (key === '3' || key === 'c' || key === 'C') handleEventChoice(2); else if (key === 'h' || key === 'H') handleEventHint(); } // Fortune: space/enter to continue if (ev && ev.type === 'fortune' && (key === ' ' || key === 'Enter')) { e.preventDefault(); advanceFromEvent(); } } else { if (key === ' ' || key === 'Enter') { e.preventDefault(); if (checkDeath()) return; advanceFromEvent(); } } break; case STATES.DEATH: if (key === ' ' || key === 'Enter') { e.preventDefault(); resetGame(); renderTitleScreen(); renderStatusBar(); } break; case STATES.WIN: if (key === ' ' || key === 'Enter') { e.preventDefault(); resetGame(); renderTitleScreen(); renderStatusBar(); } break; } }); // ===================================================================== // ANIMATION LOOP + TECH DEBT DRAIN // ===================================================================== var lastTime = 0; var drainAccumulator = 0; var DRAIN_INTERVAL = 3000; // drain 1 HP every 3 seconds var techDebtDeath = false; function gameLoop(timestamp) { var dt = timestamp - lastTime; lastTime = timestamp; // Clamp dt to avoid huge jumps if (dt > 100) dt = 16; if (gameState === STATES.TRAVEL) { scrollX += dt * 0.03; wagonWheelAngle += dt * 0.004; } else if (gameState === STATES.TITLE) { scrollX += dt * 0.01; } else { // Gentle idle scroll scrollX += dt * 0.002; } // Tech debt drain: HP ticks down during active gameplay (disabled for Ralph Wiggum) var isForgiving = PROFESSIONS[difficulty] && PROFESSIONS[difficulty].forgiving; var isActive = gameState === STATES.STOP || gameState === STATES.EVENT || gameState === STATES.RIVER; if (isActive && !deathPending && !isForgiving) { drainAccumulator += dt; while (drainAccumulator >= DRAIN_INTERVAL) { drainAccumulator -= DRAIN_INTERVAL; health--; renderStatusBar(); if (health <= 0) { techDebtDeath = true; enterDeath(); break; } } } renderCanvas(timestamp); animFrame = requestAnimationFrame(gameLoop); } // ===================================================================== // INITIALIZATION // ===================================================================== resetGame(); renderTitleScreen(); renderStatusBar(); updateMusicIndicator(); animFrame = requestAnimationFrame(gameLoop);