Spaces:
Running
Running
Download sledvolt/core.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 36.3 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/sledvolt/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/sledvolt/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/sledvolt/core.js
36.3 kB
| // Sled Volt core: the seeded mountain course, the run simulation and a bot (pure, no DOM, no three.js). | |
| // Deterministic on every device: fixed 60 Hz ticks and only + - * / floor min max abs sqrt (no sin/exp), so the | |
| // toys Worker re-plays a posted run from its inputs and gets the same score. The Roblox version (src/Course.luau, | |
| // src/Run.luau) is a line-by-line port of this file; tests/parity.json pins both to the same numbers. | |
| // (c) 2026 CyberMax. All rights reserved. | |
| export const VERSION = 1; | |
| export const TICK = 60; | |
| export const LANE_W = 6; | |
| export const RACE_LEN = 3000; | |
| export const BIOME_LEN = 1500; | |
| export const BIOMES = ['pine', 'village', 'caves', 'aurora']; | |
| export const BIOME_NAMES = { pine: 'Pine Forest', village: 'Frozen Village', caves: 'Ice Caves', aurora: 'Aurora Night' }; | |
| export const JUMP = { ticks: 38, apex: 5.6 }; | |
| export const RAMP = { ticks: 64, apex: 11 }; | |
| export const SKY_Y = 16; | |
| export const RAIL_Y = 2.2; | |
| export const POWERS = ['magnet', 'shield', 'double', 'glider', 'gift']; | |
| export const POWER_TICKS = { magnet: 480, shield: 720, double: 600, glider: 360 }; | |
| export const BOOST_TICKS = 150; | |
| export const FROST_PER = 14; | |
| export const BEAST_TICKS = 420; | |
| const G_JUMP = 8 * JUMP.apex / (JUMP.ticks * JUMP.ticks); | |
| const V_JUMP = G_JUMP * JUMP.ticks / 2; | |
| const G_RAMP = 8 * RAMP.apex / (RAMP.ticks * RAMP.ticks); | |
| const V_RAMP = G_RAMP * RAMP.ticks / 2; | |
| const SCAN = 34; | |
| /** Forward speed in units per second at distance d (40 at the start, 68 at 9,000, approaches 96). */ | |
| export function speedAt(d) { return 40 + 56 * d / (d + 9000); } | |
| export function biomeAt(z) { return BIOMES[Math.floor(z / BIOME_LEN) % 4]; } | |
| export function lapAt(z) { return Math.floor(z / (BIOME_LEN * 4)); } | |
| export function tierAt(z) { return Math.min(5, Math.floor(z / 900)); } | |
| /** Daily seed: the same course for every player on a UTC day. */ | |
| export function dailySeed(dayNumber) { return (dayNumber * 7919 + 17) % 2147483646 + 1; } | |
| // MINSTD (Park-Miller): every product stays below 2^47, exact in doubles in JS and Luau. | |
| const M = 2147483647; | |
| export function seedOf(n) { let s = Math.floor(Math.abs(n)) % (M - 1); return s + 1; } | |
| function rnd(c) { c.s = (c.s * 48271) % M; return c.s / M; } | |
| function pick(c, n) { return Math.floor(rnd(c) * n); } | |
| function weighted(c, ws) { | |
| let t = 0; for (let i = 0; i < ws.length; i++) t += ws[i]; | |
| let r = rnd(c) * t; | |
| for (let i = 0; i < ws.length; i++) { r -= ws[i]; if (r < 0) return i; } | |
| return ws.length - 1; | |
| } | |
| // ------------------------------------------------------------------ obstacle kinds | |
| // c: 'b' = solid up to height h (jump over it if h is low), 'h' = high bar (duck under, 2.0 to 4.0), 'g' = gap (be in | |
| // the air), 's' = stumble (snow drift: slows you and wakes the Rimehorn). m: moving visual ('sled', 'ball'). | |
| export const KINDS = { | |
| tree: { c: 'b', h: 12 }, rock: { c: 'b', h: 2.8 }, log: { c: 'b', h: 1.5 }, branch: { c: 'h' }, bush: { c: 's' }, | |
| fence: { c: 'b', h: 2.0 }, snowman: { c: 'b', h: 6.5 }, sledder: { c: 'b', h: 3.5, m: 'sled' }, post: { c: 'b', h: 9 }, | |
| banner: { c: 'h' }, drift: { c: 's' }, pillar: { c: 'b', h: 14 }, crystal: { c: 'b', h: 2.8 }, icicles: { c: 'h' }, | |
| icelog: { c: 'b', h: 1.5 }, boulder: { c: 'b', h: 2.8 }, ball: { c: 'b', h: 4.4, m: 'ball' }, pylon: { c: 'b', h: 13 }, | |
| gap: { c: 'g' }, hole: { c: 'g' }, | |
| }; | |
| const MIX = { | |
| pine: [['tree', 40], ['rock', 25], ['log', 15], ['branch', 12], ['bush', 8]], | |
| village: [['fence', 25], ['snowman', 25], ['sledder', 20], ['post', 10], ['banner', 12], ['drift', 8]], | |
| caves: [['pillar', 30], ['crystal', 25], ['icicles', 25], ['drift', 10], ['icelog', 10]], | |
| aurora: [['tree', 25], ['boulder', 25], ['fence', 15], ['branch', 15], ['sledder', 12], ['drift', 8]], | |
| }; | |
| const LOWS = { pine: 'log', village: 'fence', caves: 'icelog', aurora: 'fence' }; | |
| const HIGHS = { pine: 'branch', village: 'banner', caves: 'icicles', aurora: 'branch' }; | |
| const SPECIALS = { pine: ['skilift', 'avalanche'], village: ['street', 'bridge'], caves: ['tunnel', 'tunnel', 'lake'], aurora: ['avalanche', 'bridge', 'lake'] }; | |
| const SHAPES = [[70, 30, 0, 0], [55, 40, 5, 0], [40, 45, 10, 5], [30, 45, 15, 10], [22, 45, 20, 13], [15, 45, 25, 15]]; | |
| function mixKind(c, biome) { | |
| const m = MIX[biome]; const ws = []; | |
| for (let i = 0; i < m.length; i++) ws.push(m[i][1]); | |
| return m[weighted(c, ws)][0]; | |
| } | |
| /** Can a lane with this kind be passed without changing lane? (by jumping, ducking, or riding through a stumble) */ | |
| export function passable(k) { const d = KINDS[k]; return d.c !== 'b' || d.h <= 2.8 || d.m === 'ball'; } | |
| // ------------------------------------------------------------------ course | |
| /** A course is generated lazily, in z order, as the run needs it. Same seed = same course everywhere. */ | |
| export function makeCourse(seed) { | |
| const c = { seed: seedOf(seed), s: seedOf(seed), items: [], pending: [], longs: [], sky: [], decor: [], id: 0, z: 0, rows: 0, | |
| nextPow: 420, nextSpecial: 700, special: null, lastWall: -9, keep: null, genTo: 0, railEnd: 0 }; | |
| rnd(c); rnd(c); | |
| return c; | |
| } | |
| function add(c, list, it) { c.id += 1; it.id = c.id; list.push(it); return it; } | |
| function addItem(c, k, l, z, extra) { | |
| const it = { k, l, z, e: z + (k === 'gap' || k === 'hole' ? 9 : k === 'sledder' ? 3 : 1.6) }; | |
| if (extra) for (const key in extra) it[key] = extra[key]; | |
| return add(c, c.pending, it); | |
| } | |
| const byZ = (a, b) => (a.z < b.z ? -1 : a.z > b.z ? 1 : a.id - b.id); | |
| /** Move pending items up to z into the course list, in strict (z, id) order. */ | |
| function flush(c, z) { | |
| c.pending.sort(byZ); | |
| let n = 0; | |
| while (n < c.pending.length && c.pending[n].z <= z) { c.items.push(c.pending[n]); n++; } | |
| c.pending.splice(0, n); | |
| } | |
| function laneOk(c, l, z) { return !(c.keep && c.keep.l === l && z <= c.keep.until); } | |
| /** Lane kinds for one row: an array of 3 (lane -1, 0, 1) with a kind name or '' for empty. */ | |
| function rowShape(c, z, biome, tier) { | |
| const row = ['', '', '']; | |
| if (z < 300) { row[pick(c, 3)] = mixKind(c, biome); return row; } | |
| let shape = weighted(c, SHAPES[tier]); | |
| if (shape === 3 && c.rows - c.lastWall < 4) shape = 1; | |
| if (shape === 0) row[pick(c, 3)] = mixKind(c, biome); | |
| else if (shape === 1) { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = mixKind(c, biome); } | |
| else if (shape === 2) { | |
| for (let i = 0; i < 3; i++) row[i] = mixKind(c, biome); | |
| if (!passable(row[0]) && !passable(row[1]) && !passable(row[2])) row[pick(c, 3)] = LOWS[biome]; | |
| } else { | |
| c.lastWall = c.rows; | |
| const k = rnd(c) < 0.5 ? LOWS[biome] : HIGHS[biome]; | |
| for (let i = 0; i < 3; i++) row[i] = k; | |
| } | |
| return row; | |
| } | |
| function specialRow(c, sp, z, biome) { | |
| const row = ['', '', '']; | |
| const t = sp.type; | |
| if (t === 'skilift') { row[sp.n % 2 === 0 ? 0 : 2] = 'pylon'; if (rnd(c) < 0.35) row[1] = 'rock'; } | |
| else if (t === 'avalanche') { const a = pick(c, 3); row[a] = 'ball'; if (rnd(c) < 0.4) row[(a + 1 + pick(c, 2)) % 3] = 'ball'; } | |
| else if (t === 'street') { | |
| if (sp.n % 3 === 2) { row[0] = 'fence'; row[1] = 'fence'; row[2] = 'fence'; c.lastWall = c.rows; } | |
| else { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.5 ? 'snowman' : 'sledder'; } | |
| } else if (t === 'bridge') { const solid = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== solid && rnd(c) < 0.7) row[i] = 'gap'; } | |
| else if (t === 'tunnel') { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.55 ? 'icicles' : 'pillar'; if (sp.n % 4 === 3) { row[0] = 'icicles'; row[1] = 'icicles'; row[2] = 'icicles'; } } | |
| else if (t === 'lake') { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.6 ? 'hole' : 'crystal'; } | |
| return row; | |
| } | |
| function startSpecial(c, z, biome) { | |
| const opts = SPECIALS[biome]; | |
| const type = opts[pick(c, opts.length)]; | |
| const len = type === 'tunnel' ? 260 : type === 'bridge' ? 170 : 230; | |
| c.special = { type, z0: z, z1: z + len, n: 0 }; | |
| add(c, c.decor, { k: 'sign', l: 0, z: z - 120, e: z - 120, t: type }); | |
| add(c, c.longs, { k: 'zone', l: 0, z, e: z + len, t: type }); | |
| if (type === 'lake') add(c, c.longs, { k: 'ice', l: 0, z, e: z + len }); | |
| c.nextSpecial = z + len + 550 + pick(c, 250); | |
| } | |
| /** Generate rows until the course reaches zMax. */ | |
| export function genUntil(c, zMax) { | |
| while (c.z < zMax) genRow(c); | |
| c.genTo = c.z; | |
| } | |
| function genRow(c) { | |
| const z0 = c.z; | |
| const tier = tierAt(z0); | |
| const v = speedAt(z0); | |
| let gap = c.rows === 0 ? 60 : z0 < 300 ? v * 1.2 : v * (0.62 + (0.9 - tier * 0.12) * rnd(c)); | |
| if (c.prevWall || c.prevGap) gap = Math.max(gap, v * 0.95); | |
| if (c.special) gap = Math.max(gap, v * 0.7); | |
| if (c.railEnd) { gap = Math.max(gap, c.railEnd - z0 + v * 0.6); c.railEnd = 0; } // time to come off a rail | |
| const z1 = z0 + gap; | |
| const biome = biomeAt(z1); | |
| if (!c.special && z1 >= c.nextSpecial && z1 > 400) startSpecial(c, z1, biome); | |
| let row; | |
| if (c.special && z1 < c.special.z1) { row = specialRow(c, c.special, z1, biome); c.special.n += 1; } | |
| else { if (c.special) c.special = null; row = rowShape(c, z1, biome, tier); } | |
| // a ramp's flight lane stays clear of tall things | |
| for (let i = 0; i < 3; i++) if (row[i] && !laneOk(c, i - 1, z1)) row[i] = ''; | |
| c.prevWall = row[0] !== '' && row[0] === row[1] && row[1] === row[2]; | |
| c.prevGap = row[0] === 'gap' || row[1] === 'gap' || row[2] === 'gap' || row[0] === 'hole' || row[1] === 'hole' || row[2] === 'hole'; | |
| const free = []; | |
| for (let i = 0; i < 3; i++) if (row[i] === '') free.push(i - 1); | |
| gapContent(c, z0, z1, row, free, biome); | |
| for (let i = 0; i < 3; i++) { | |
| const k = row[i]; | |
| if (!k) continue; | |
| const l = i - 1; | |
| if (k === 'sledder') { | |
| const l0 = rnd(c) < 0.4 ? Math.max(-1, Math.min(1, l + (rnd(c) < 0.5 ? -1 : 1))) : l; | |
| addItem(c, k, l, z1, { l0: l0 === l ? l : l0 }); | |
| } else if (k === 'ball') addItem(c, k, l, z1, { x0: rnd(c) < 0.5 ? -20 : 20 }); | |
| else addItem(c, k, l, z1); | |
| } | |
| c.rows += 1; | |
| c.z = z1; | |
| flush(c, z1); | |
| } | |
| function gapContent(c, z0, z1, row, free, biome) { | |
| const span = z1 - z0; | |
| if (c.rows === 0 || span < 18) return; | |
| const v = speedAt(z0); | |
| // power-up in a free lane, half way | |
| if (z1 >= c.nextPow && free.length > 0) { | |
| const l = free[pick(c, free.length)]; | |
| const ws = [25, 20, 20, biome === 'caves' || (c.special && c.special.type === 'tunnel') ? 0 : 15, 20]; | |
| const p = POWERS[weighted(c, ws)]; | |
| const zp = z0 + span * 0.5; | |
| addItem(c, 'pow', l, zp, { p }); | |
| c.nextPow = zp + 380 + pick(c, 180); | |
| if (p === 'glider') skyCoins(c, zp + 24, zp + v * 6.4); | |
| return; | |
| } | |
| const r = rnd(c); | |
| if (r < 0.1 && free.length > 0 && !c.special && span > 40) { // ramp: launch over the next row | |
| const l = free[pick(c, free.length)]; | |
| const zr = z1 - 16; | |
| addItem(c, 'ramp', l, zr); | |
| const fly = RAMP.ticks * speedAt(zr) / TICK; | |
| c.keep = { l, until: zr + fly + speedAt(zr) * 0.5 }; | |
| for (let i = 1; i <= 7; i++) { | |
| const t = i / 8; const y = 4 * RAMP.apex * t * (1 - t) + 1.2; | |
| addItem(c, 'coin', l, zr + fly * t, { y }); | |
| } | |
| return; | |
| } | |
| if (r < 0.2 && free.length + countLow(row) > 0 && !c.special && biome !== 'caves' && span > 34) { // grind rail | |
| const lanes = []; | |
| for (let i = 0; i < 3; i++) if (row[i] === '' || (KINDS[row[i]].c === 'b' && KINDS[row[i]].h <= 2)) lanes.push(i - 1); | |
| const l = lanes[pick(c, lanes.length)]; | |
| const zs = z0 + 8; const ze = z1 + Math.min(16, span * 0.4); | |
| add(c, c.longs, { k: 'rail', l, z: zs, e: ze }); | |
| c.railEnd = ze; | |
| for (let z = zs + 4; z < ze - 2; z += 4.5) addItem(c, 'coin', l, z, { y: RAIL_Y + 1.4 }); | |
| return; | |
| } | |
| if (r < 0.36) { addItem(c, 'frost', free.length ? free[pick(c, free.length)] : 0, z0 + span * 0.5, { y: 1.4 }); return; } | |
| if (free.length > 0) { // coin line that shows the safe lane | |
| const l = free[pick(c, free.length)]; | |
| const n = Math.min(6, Math.floor((span - 10) / 4.5)); | |
| for (let i = 0; i < n; i++) addItem(c, 'coin', l, z1 - 6 - (n - 1 - i) * 4.5, { y: 1.2 }); | |
| } else { // a wall: coins arc over the middle | |
| for (let i = -2; i <= 2; i++) { | |
| const t = (i + 2.5) / 5; | |
| addItem(c, 'coin', 0, z1 + i * 3.4, { y: 1.2 + 4 * (JUMP.apex - 0.6) * t * (1 - t) }); | |
| } | |
| } | |
| } | |
| function countLow(row) { let n = 0; for (let i = 0; i < 3; i++) if (row[i] && KINDS[row[i]].c === 'b' && KINDS[row[i]].h <= 2) n++; return n; } | |
| function skyCoins(c, za, zb) { | |
| let n = 0; | |
| for (let z = za; z < zb; z += 7) { add(c, c.sky, { k: 'coin', l: (n % 6 < 3 ? (n % 3) - 1 : 1 - (n % 3)), z, e: z, y: SKY_Y + 0.6 }); n++; } | |
| } | |
| /** Where a moving thing is drawn (x) when the player is at distance d. Collisions use its final lane at z. */ | |
| export function itemX(it, d) { | |
| const x1 = it.l * LANE_W; | |
| if (it.k === 'sledder' && it.l0 !== undefined && it.l0 !== it.l) { | |
| const t = Math.max(0, Math.min(1, (d - (it.z - 130)) / 30)); | |
| return it.l0 * LANE_W + (x1 - it.l0 * LANE_W) * t * t * (3 - 2 * t); | |
| } | |
| if (it.k === 'ball') { const t = Math.max(0, Math.min(1, (d - (it.z - 75)) / 75)); return it.x0 + (x1 - it.x0) * t; } | |
| return x1; | |
| } | |
| /** Where a sledder is drawn along the course (it moves forward at 45% of your speed until you meet). */ | |
| export function itemZ(it, d) { if (it.k === 'sledder' && d < it.z) return it.z - (it.z - d) * 0.45; return it.z; } | |
| // ------------------------------------------------------------------ run | |
| export function powerTicks(p, level) { return POWER_TICKS[p] + Math.min(300, Math.floor(Math.max(0, level - 1) / 2) * 30); } | |
| /** opts: { level (Mission Level = score multiplier, 1 for daily/race), finish (race length or 0) } */ | |
| export function newRun(seed, opts = {}) { | |
| const course = makeCourse(seed); | |
| genUntil(course, 400); | |
| return { | |
| course, tick: 0, d: 0, v: 40, lane: 0, x: 0, fromX: 0, laneT: 99, laneDur: 9, y: 0, vy: 0, g: 0, air: '', duck: 0, | |
| grind: 0, ice: false, coins: 0, score: 0, frost: 0, level: Math.max(1, Math.min(30, Math.floor(opts.level || 1))), | |
| finish: opts.finish || 0, finished: 0, alive: true, cause: '', pw: { magnet: 0, shield: 0, double: 0, glider: 0 }, | |
| boost: 0, beast: 0, slow: 0, grace: 0, combo: 1, comboT: 0, streak: 0, streakT: 0, buf: '', bufT: 0, lastLaneT: -99, | |
| jumpT: -99, duckT0: -99, done: [], lo: 0, llo: 0, slo: 0, | |
| st: { jumps: 0, ducks: 0, grind: 0, near: 0, boosts: 0, powers: 0, smash: 0, coins: 0, ramps: 0, cleanT: 0, bestClean: 0, stumbles: 0 }, | |
| ev: [], | |
| }; | |
| } | |
| function ev(r, type, data) { if (!r.quiet) r.ev.push(data ? { type, ...data } : { type }); } | |
| function multOf(r) { return r.level * (r.pw.double > 0 ? 2 : 1) * (r.boost > 0 ? 2 : 1); } | |
| function bonus(r, pts, type) { const p = pts * multOf(r) * r.combo; r.score += p; ev(r, type, { pts: p }); } | |
| function comboUp(r) { r.combo = Math.min(5, r.combo + 1); r.comboT = 0; } | |
| function tryInput(r, a) { | |
| if (a === 'L' || a === 'R') { | |
| const target = Math.max(-1, Math.min(1, r.lane + (a === 'L' ? -1 : 1))); | |
| if (target === r.lane) { ev(r, 'edge'); return true; } | |
| r.fromX = r.x; r.lane = target; r.laneT = 0; r.laneDur = r.ice ? 18 : 9; r.lastLaneT = r.tick; | |
| ev(r, 'lane'); | |
| return true; | |
| } | |
| if (a === 'U') { | |
| if (r.pw.glider > 0) return true; | |
| if (r.air === '' || r.grind) { r.air = 'jump'; r.vy = V_JUMP; r.g = G_JUMP; r.duck = 0; r.grind = 0; r.jumpT = r.tick; r.st.jumps++; ev(r, 'jump'); return true; } | |
| return false; | |
| } | |
| if (a === 'D') { | |
| if (r.pw.glider > 0) return true; | |
| if (r.air !== '' && !r.grind) { r.vy = Math.min(r.vy, -1.6); r.g = G_JUMP; r.duck = 40; ev(r, 'slam'); return true; } | |
| r.grind = 0; r.duck = 36; r.duckT0 = r.tick; r.st.ducks++; ev(r, 'duck'); return true; | |
| } | |
| if (a === 'B') { | |
| if (r.frost >= 100 && r.boost <= 0) { r.frost = 0; r.boost = BOOST_TICKS; r.st.boosts++; ev(r, 'boost'); } | |
| return true; | |
| } | |
| return true; | |
| } | |
| function hit(r, it, kind) { // returns true when the run continues | |
| r.done[it.id] = true; | |
| if (r.boost > 0) { r.st.smash++; bonus(r, 50, 'smash'); ev(r, 'smashed', { id: it.id }); return true; } | |
| if (r.pw.shield > 0) { r.pw.shield = 0; r.grace = 40; r.st.smash++; ev(r, 'shieldbreak', { id: it.id }); if (kind === 'g') { r.air = 'jump'; r.vy = V_JUMP; r.g = G_JUMP; } return true; } | |
| r.alive = false; r.cause = kind === 'g' ? 'fell' : it.k; ev(r, 'crash', { id: it.id, k: it.k }); | |
| return false; | |
| } | |
| function stumble(r, it) { | |
| if (it) r.done[it.id] = true; | |
| if (r.boost > 0) { if (it) { r.st.smash++; bonus(r, 50, 'smash'); } return; } | |
| r.st.stumbles++; r.st.cleanT = 0; r.slow = 30; r.combo = 1; | |
| if (r.beast > 0) { r.alive = false; r.cause = 'beast'; ev(r, 'caught'); return; } | |
| r.beast = BEAST_TICKS; ev(r, 'stumble', { id: it ? it.id : 0 }); | |
| } | |
| /** Advance one tick. action: '', 'L', 'R', 'U', 'D' or 'B'. Fills r.ev with this tick's events. */ | |
| export function step(r, action) { | |
| r.ev.length = 0; | |
| if (!r.alive || r.finished) return r; | |
| const c = r.course; | |
| r.tick += 1; | |
| if (action) { r.buf = action; r.bufT = 7; } | |
| if (r.buf) { if (tryInput(r, r.buf)) r.buf = ''; else if (--r.bufT <= 0) r.buf = ''; } | |
| // speed | |
| const base = speedAt(r.d); | |
| let mul = 1; | |
| if (r.duck > 0 && r.air === '') mul += 0.12; | |
| if (r.boost > 0) mul += 0.5; | |
| if (r.ice) mul += 0.1; | |
| if (r.slow > 0) { mul -= 0.3; r.slow -= 1; } | |
| r.v = base * mul; | |
| const dPrev = r.d; | |
| r.d += r.v / TICK; | |
| if (c.genTo < r.d + 320) genUntil(c, r.d + 420); | |
| // lateral | |
| if (r.laneT < r.laneDur) { | |
| r.laneT += 1; | |
| const t = r.laneT / r.laneDur; | |
| r.x = r.fromX + (r.lane * LANE_W - r.fromX) * t * t * (3 - 2 * t); | |
| } else r.x = r.lane * LANE_W; | |
| // vertical | |
| if (r.pw.glider > 0) { | |
| r.pw.glider -= 1; r.y += (SKY_Y - r.y) * 0.08; r.air = 'glide'; r.vy = 0; r.grind = 0; | |
| if (r.pw.glider === 0) { r.air = 'jump'; r.g = G_JUMP; r.vy = 0; r.grace = 80; ev(r, 'glideEnd'); } | |
| } else if (r.grind) { | |
| r.y = RAIL_Y; | |
| } else if (r.air !== '') { | |
| r.y += r.vy; r.vy -= r.g; | |
| if (r.y <= 0) { | |
| const was = r.air; r.y = 0; r.vy = 0; r.air = ''; | |
| ev(r, 'land', { from: was }); | |
| if (was === 'ramp') { r.st.ramps++; bonus(r, 50, 'stunt'); } | |
| } | |
| } | |
| if (r.duck > 0 && r.air === '') r.duck -= 1; | |
| // long things: ice, rails | |
| r.ice = false; | |
| let onRail = 0; | |
| while (r.llo < c.longs.length && c.longs[r.llo].e < r.d - 300) r.llo++; | |
| for (let i = r.llo; i < c.longs.length; i++) { | |
| const L = c.longs[i]; | |
| if (L.z > r.d + 300) break; | |
| if (L.e < r.d - 2 || L.z > r.d + 1) continue; | |
| if (L.k === 'ice') r.ice = true; | |
| if (L.k === 'rail' && Math.abs(r.x - L.l * LANE_W) < 2.2 && r.y <= RAIL_Y + 0.6 && r.pw.glider <= 0 && !(r.air === 'jump' && r.vy > 0)) onRail = L.id; | |
| } | |
| if (onRail) { | |
| if (!r.grind) { r.grind = onRail; r.air = ''; r.vy = 0; r.y = RAIL_Y; ev(r, 'grind'); } | |
| const gd = r.d - dPrev; | |
| r.st.grind += gd; r.score += 3 * gd * multOf(r) * r.combo; | |
| } else if (r.grind) { r.grind = 0; r.air = 'jump'; r.vy = 0; r.g = G_JUMP; ev(r, 'grindEnd'); } | |
| // obstacles and pickups | |
| while (r.lo < c.items.length && c.items[r.lo].e < r.d - 4) r.lo++; | |
| const isDuck = r.duck > 0 && r.y < 0.5; | |
| for (let i = r.lo; i < c.items.length; i++) { | |
| const it = c.items[i]; | |
| if (it.z > r.d + SCAN) break; // items are in strict z order (flush) | |
| if (r.done[it.id]) continue; | |
| const k = it.k; | |
| if (k === 'coin' || k === 'frost' || k === 'pow') { | |
| const dz = it.z - r.d; | |
| const magnet = k === 'coin' && r.pw.magnet > 0 && dz > -2 && dz < 30; | |
| const near = Math.abs(dz) < 1.8 && Math.abs(r.x - it.l * LANE_W) < 2.4 && Math.abs(r.y - (it.y || 1.2)) < 2.6; | |
| if (magnet || near) collect(r, it); | |
| continue; | |
| } | |
| if (k === 'ramp') { | |
| if (r.air === '' && !r.grind && it.z <= r.d && it.z > dPrev - 0.01 && Math.abs(r.x - it.l * LANE_W) < 2.6 && r.pw.glider <= 0) { | |
| r.done[it.id] = true; r.air = 'ramp'; r.vy = V_RAMP; r.g = G_RAMP; r.duck = 0; ev(r, 'ramp'); | |
| } | |
| continue; | |
| } | |
| const def = KINDS[k]; | |
| if (!def) continue; | |
| if (r.grace > 0 && it.z - 1.2 <= r.d) { r.done[it.id] = true; continue; } | |
| if (it.z - 1.2 > r.d || it.e + 1.2 < dPrev) { | |
| if (it.e + 1.2 < r.d && !r.done[it.id]) { r.done[it.id] = true; nearMiss(r, it, def); } | |
| continue; | |
| } | |
| const dx = Math.abs(r.x - it.l * LANE_W); | |
| if (dx >= 3.2) continue; | |
| if (def.c === 'g') { | |
| if (r.y <= 0.05 && !r.grind && r.pw.glider <= 0 && dx < 2.6) { if (!hit(r, it, 'g')) return r; } | |
| continue; | |
| } | |
| if (def.c === 's') { if (r.y < 1) stumble(r, it); else r.done[it.id] = true; if (!r.alive) return r; continue; } | |
| if (def.c === 'h') { | |
| if (isDuck || r.y > 4.0) continue; | |
| } else if (r.y > def.h) continue; | |
| if (dx < 1.7) { if (!hit(r, it, def.c)) return r; } | |
| else { // side-swipe: bounce back to the lane you came from | |
| r.done[it.id] = true; | |
| const back = Math.floor(r.fromX / LANE_W + 0.5); | |
| r.fromX = r.x; r.lane = Math.max(-1, Math.min(1, back)); r.laneT = 0; r.laneDur = 9; | |
| ev(r, 'bump', { id: it.id }); | |
| stumble(r, null); | |
| if (!r.alive) return r; | |
| } | |
| } | |
| if (r.pw.glider > 0 || r.y > 10) { | |
| while (r.slo < c.sky.length && c.sky[r.slo].z < r.d - 500) r.slo++; | |
| for (let i = r.slo; i < c.sky.length; i++) { | |
| const it = c.sky[i]; | |
| if (it.z > r.d + SCAN) break; | |
| if (r.done[it.id] || it.z < r.d - 3) continue; | |
| if (Math.abs(it.z - r.d) < 2.2 && Math.abs(r.x - it.l * LANE_W) < 3 && Math.abs(r.y - it.y) < 3.5) collect(r, it); | |
| } | |
| } | |
| // timers | |
| if (r.pw.magnet > 0) r.pw.magnet -= 1; | |
| if (r.pw.shield > 0) r.pw.shield -= 1; | |
| if (r.pw.double > 0) r.pw.double -= 1; | |
| if (r.boost > 0) { r.boost -= 1; if (r.boost === 0) { r.grace = Math.max(r.grace, 30); ev(r, 'boostEnd'); } } // half a second to react after VOLT | |
| if (r.grace > 0) r.grace -= 1; | |
| if (r.beast > 0) { r.beast -= 1; if (r.beast === 0) ev(r, 'beastGone'); } | |
| r.comboT += 1; if (r.comboT > 180 && r.combo > 1) { r.combo = 1; ev(r, 'comboEnd'); } | |
| r.streakT += 1; if (r.streakT > 60) r.streak = 0; | |
| r.st.cleanT += 1; if (r.st.cleanT > r.st.bestClean) r.st.bestClean = r.st.cleanT; | |
| // score: distance x multiplier | |
| r.score += (r.d - dPrev) * multOf(r); | |
| if (r.finish && r.d >= r.finish) { r.finished = r.tick; ev(r, 'finish'); } | |
| return r; | |
| } | |
| function collect(r, it) { | |
| r.done[it.id] = true; | |
| if (it.k === 'coin') { | |
| r.coins += 1; r.st.coins += 1; r.streak += 1; r.streakT = 0; | |
| if (r.streak % 10 === 0) comboUp(r); | |
| ev(r, 'coin', { id: it.id, n: r.streak }); | |
| } else if (it.k === 'frost') { r.frost = Math.min(100, r.frost + FROST_PER); ev(r, 'frost', { id: it.id }); } | |
| else if (it.k === 'pow') { | |
| r.st.powers += 1; | |
| let p = it.p; | |
| if (p === 'gift') { | |
| const g = (it.id * 7 + r.course.seed) % 6; | |
| if (g < 3) { const n = 25 + g * 50 + (g === 2 ? 25 : 0); r.coins += n; ev(r, 'gift', { id: it.id, coins: n }); return; } | |
| if (g === 3) { r.frost = 100; ev(r, 'gift', { id: it.id, frost: 1 }); return; } | |
| p = g === 4 ? 'magnet' : 'shield'; | |
| ev(r, 'gift', { id: it.id, p }); | |
| } | |
| r.pw[p] = powerTicks(p, r.level); | |
| if (p === 'glider') { r.grind = 0; r.duck = 0; } | |
| ev(r, 'power', { id: it.id, p }); | |
| } | |
| } | |
| function nearMiss(r, it, def) { | |
| const dx = Math.abs(r.x - it.l * LANE_W); | |
| let close = false; | |
| if (def.c === 'b' && def.h > 2.8 && dx > 3.2 && dx < 7 && r.tick - r.lastLaneT < 18) close = true; | |
| if (def.c === 'b' && def.h <= 2.8 && dx < 3.2 && r.tick - r.jumpT < 16) close = true; | |
| if (def.c === 'h' && dx < 3.2 && r.tick - r.duckT0 < 16) close = true; | |
| if (def.c === 'g' && dx < 3.2 && r.tick - r.jumpT < 10) close = true; | |
| if (close) { r.st.near += 1; comboUp(r); bonus(r, 25, 'near'); } | |
| } | |
| /** Replay inputs: [[tick, action], ...] sorted by tick. Stops at a crash, a finish or maxTicks. */ | |
| export function replay(seed, inputs, opts = {}) { | |
| const r = newRun(seed, opts); | |
| r.quiet = !!opts.quiet; | |
| const max = opts.maxTicks || 60 * 60 * 20; | |
| let j = 0; | |
| while (r.alive && !r.finished && r.tick < max) { | |
| const t = r.tick + 1; | |
| let a = ''; | |
| while (j < inputs.length && inputs[j][0] < t) j++; | |
| if (j < inputs.length && inputs[j][0] === t) { a = inputs[j][1]; j++; } | |
| step(r, a); | |
| } | |
| return r; | |
| } | |
| export function finalScore(r) { return Math.floor(r.score); } | |
| // ------------------------------------------------------------------ inputs <-> compact string (links, board posts) | |
| const ACTS = 'LRUDB'; | |
| const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; | |
| /** Encode inputs as base64url of (delta-tick varint, action) pairs. ~2 chars per input. */ | |
| export function encodeInputs(inputs) { | |
| const bytes = []; let last = 0; | |
| for (const [t, a] of inputs) { | |
| let dt = t - last; last = t; | |
| const code = ACTS.indexOf(a); | |
| let v = dt * 5 + code; | |
| while (v >= 32) { bytes.push(32 | (v & 31)); v = Math.floor(v / 32); } | |
| bytes.push(v); | |
| } | |
| let s = ''; for (const b of bytes) s += B64[b]; | |
| return s; | |
| } | |
| export function decodeInputs(s) { | |
| const out = []; let last = 0, v = 0, mul = 1; | |
| if (typeof s !== 'string' || s.length > 40000) return null; | |
| for (const ch of s) { | |
| const b = B64.indexOf(ch); | |
| if (b < 0) return null; | |
| v += (b & 31) * mul; mul *= 32; | |
| if (!(b & 32)) { | |
| const code = v % 5; const dt = Math.floor(v / 5); | |
| last += dt; out.push([last, ACTS[code]]); v = 0; mul = 1; | |
| if (dt === 0) return null; // two inputs in one tick: not from a real run | |
| } | |
| } | |
| return out; | |
| } | |
| /** Re-play a posted daily run (used by the toys Worker): { ok, score, d, ticks }. */ | |
| export function verifyRun(seed, encoded, opts = {}) { | |
| const inputs = decodeInputs(encoded); | |
| if (!inputs || inputs.length > 20000) return { ok: false }; | |
| const r = replay(seed, inputs, { level: 1, quiet: true, maxTicks: Math.min(opts.maxTicks || 1e9, 60 * 60 * 30) }); | |
| return { ok: true, score: finalScore(r), d: Math.floor(r.d), ticks: r.tick, alive: r.alive }; | |
| } | |
| /** Daily board entry point for the toys Worker: d = YYYY-MM-DD (the game's daily date), taps = encoded inputs. | |
| * Day #1 = 2026-10-02 (the CyberMax daily epoch). Runs are capped at 6 minutes so a re-play stays cheap. */ | |
| export function boardRun(d, taps) { | |
| const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(d || '')); | |
| if (!m) return { ok: false }; | |
| const day = Math.round((Date.UTC(+m[1], +m[2] - 1, +m[3]) - Date.UTC(2026, 9, 2)) / 864e5) + 1; | |
| const v = verifyRun(dailySeed(day), taps, { maxTicks: 60 * 60 * 6 }); | |
| return v.ok ? { ok: true, score: v.score } : { ok: false }; | |
| } | |
| // ------------------------------------------------------------------ bot (tests, the attract mode and the trailer) | |
| /** One tick of a careful bot: returns '', 'L', 'R', 'U', 'D' or 'B'. skill 0..1 (1 = never misses). */ | |
| export function bot(r, skill = 1, roll = Math.random) { | |
| const c = r.course; | |
| if (r.frost >= 100 && r.boost <= 0) return 'B'; | |
| if (r.pw.glider > 0) return ''; | |
| const v = r.v; | |
| // rows ahead: threat per lane | |
| const rows = []; | |
| for (let i = r.lo; i < c.items.length; i++) { | |
| const it = c.items[i]; | |
| if (it.z > r.d + v * 1.6) break; | |
| if (r.done[it.id] || it.e < r.d - 1) continue; | |
| const def = KINDS[it.k]; | |
| if (!def) continue; | |
| let row = rows.find((w) => Math.abs(w.z - it.z) < 6); | |
| if (!row) { row = { z: it.z, lanes: ['', '', ''], len: [0, 0, 0] }; rows.push(row); } | |
| const need = def.c === 'g' ? 'jump' : def.c === 'h' ? 'duck' : def.c === 's' ? 'jump' : (def.h <= 4.4 ? 'jump' : 'block'); | |
| row.lanes[it.l + 1] = need; | |
| row.len[it.l + 1] = it.e - it.z; | |
| } | |
| const cur = r.lane; | |
| // in the air you can't jump again: a jump-row just after the landing point is a wall | |
| if (r.air !== '' && !r.grind) { | |
| let t = 0, y = r.y, vy = r.vy; | |
| while (y > 0 && t < 200) { y += vy; vy -= r.g; t++; } | |
| const landD = r.d + t * v / TICK; | |
| for (const row of rows) for (let i = 0; i < 3; i++) if (row.lanes[i] === 'jump' && row.z - (r.air === 'ramp' ? r.d + 1 : landD - 3) > 0 && row.z - landD < v * 8 / TICK + 1.5) row.lanes[i] = 'block'; | |
| } | |
| const here = rows.filter((w) => w.z - 1.5 <= r.d); | |
| const ahead = rows.filter((w) => w.z - 1.5 > r.d); | |
| rows.length = 0; rows.push(...ahead); | |
| const under = (l) => here.some((w) => w.lanes[l + 1] !== ''); | |
| let target = cur; | |
| const commit = rows.length && rows[0].lanes[cur + 1] !== 'block' && (rows[0].z - 1.5 - r.d) / (v / TICK) < 14; | |
| if (rows.length && !commit) { | |
| const score = (l) => { | |
| let s = 0; | |
| const a = rows[0].lanes[l + 1]; | |
| if (a === 'block') s += 1000; else if (a) s += 3; | |
| if (rows[1]) { const b = rows[1].lanes[l + 1]; if (b === 'block') s += 4; else if (b) s += 1; } | |
| s += Math.abs(l - cur) * 1.5; | |
| if (rail(r, l)) s -= 2; | |
| return s; | |
| }; | |
| let best = 1e9; | |
| for (let l = -1; l <= 1; l++) { | |
| // can we reach lane l before row 0? each lane takes laneDur ticks | |
| const ticksTo = (rows[0].z - 1.5 - r.d) / (v / TICK); | |
| const need = Math.abs(l - cur) * (r.ice ? 18 : 9) + 2; | |
| if (l !== cur && ((ticksTo < need && rows[0].lanes[l + 1] === 'block') || under(l) || (Math.abs(l - cur) === 2 && under(0)))) continue; | |
| const s = score(l) + (ticksTo < need ? 50 : 0); | |
| if (s < best) { best = s; target = l; } | |
| } | |
| } | |
| if (r.laneT >= r.laneDur && target !== cur) return target < cur ? 'L' : 'R'; | |
| // timing for the row in our lane | |
| for (const row of here.concat(rows)) { | |
| const a = row.lanes[cur + 1]; | |
| if (!a) continue; | |
| const dist = row.z - r.d; | |
| if (a === 'jump' && r.air === '' && !r.grind) { | |
| const lead = row.len[cur + 1] > 4 ? 3 : 15; | |
| if (dist < v * lead / TICK + 1.5 && dist > -1) return skill >= 1 || roll() < skill ? 'U' : ''; | |
| } | |
| if (a === 'jump' && r.grind && dist < v * 15 / TICK + 1.5) return ''; | |
| if (a === 'duck') { | |
| if (r.air !== '' && dist < v * 14 / TICK + 2) return 'D'; | |
| if (r.air === '' && r.duck < 4 && dist < v * 10 / TICK + 2 && dist > -1) return 'D'; | |
| } | |
| break; | |
| } | |
| return ''; | |
| } | |
| function rail(r, l) { | |
| const c = r.course; | |
| for (const L of c.longs) if (L.k === 'rail' && L.l === l && L.z > r.d && L.z < r.d + 40) return true; | |
| return false; | |
| } | |
| // ------------------------------------------------------------------ progression (shared numbers) | |
| export const MISSIONS = [ | |
| { id: 'coins1', text: 'Collect 150 coins in one run', stat: 'coins', goal: 150, run: true }, | |
| { id: 'coins2', text: 'Collect 400 coins in one run', stat: 'coins', goal: 400, run: true }, | |
| { id: 'jumps', text: 'Jump 20 times', stat: 'jumps', goal: 20 }, | |
| { id: 'jumps2', text: 'Jump 60 times', stat: 'jumps', goal: 60 }, | |
| { id: 'ducks', text: 'Tuck under 15 things', stat: 'ducks', goal: 15 }, | |
| { id: 'grind', text: 'Grind 300 units of rail', stat: 'grind', goal: 300 }, | |
| { id: 'grind2', text: 'Grind 150 units in one run', stat: 'grind', goal: 150, run: true }, | |
| { id: 'near', text: 'Get 10 near misses', stat: 'near', goal: 10 }, | |
| { id: 'near2', text: 'Get 8 near misses in one run', stat: 'near', goal: 8, run: true }, | |
| { id: 'boost', text: 'VOLT boost 3 times', stat: 'boosts', goal: 3 }, | |
| { id: 'boost2', text: 'VOLT boost twice in one run', stat: 'boosts', goal: 2, run: true }, | |
| { id: 'smash', text: 'Smash 5 obstacles', stat: 'smash', goal: 5 }, | |
| { id: 'powers', text: 'Grab 6 power-ups', stat: 'powers', goal: 6 }, | |
| { id: 'ramps', text: 'Land 5 ramp jumps', stat: 'ramps', goal: 5 }, | |
| { id: 'dist1', text: 'Sled 1,500 units in one run', stat: 'dist', goal: 1500, run: true }, | |
| { id: 'dist2', text: 'Sled 3,000 units in one run', stat: 'dist', goal: 3000, run: true }, | |
| { id: 'dist3', text: 'Reach the Ice Caves (3,000)', stat: 'dist', goal: 3000, run: true }, | |
| { id: 'dist4', text: 'Reach Aurora Night (4,500)', stat: 'dist', goal: 4500, run: true }, | |
| { id: 'score1', text: 'Score 5,000 in one run', stat: 'score', goal: 5000, run: true }, | |
| { id: 'score2', text: 'Score 20,000 in one run', stat: 'score', goal: 20000, run: true }, | |
| { id: 'clean', text: 'Go 60 s without a stumble', stat: 'clean', goal: 60, run: true }, | |
| { id: 'runs', text: 'Play 5 runs', stat: 'runs', goal: 5 }, | |
| { id: 'daily', text: 'Play the daily run', stat: 'daily', goal: 1 }, | |
| { id: 'coinsAll', text: 'Collect 1,000 coins in total', stat: 'coins', goal: 1000 }, | |
| ]; | |
| /** The run's stats in mission terms. */ | |
| export function runStats(r, daily) { | |
| return { coins: r.st.coins, jumps: r.st.jumps, ducks: r.st.ducks, grind: Math.floor(r.st.grind), near: r.st.near, boosts: r.st.boosts, | |
| smash: r.st.smash, powers: r.st.powers, ramps: r.st.ramps, dist: Math.floor(r.d), score: finalScore(r), | |
| clean: Math.floor(r.st.bestClean / TICK), runs: 1, daily: daily ? 1 : 0 }; | |
| } | |
| /** Mission pick for a mission level: 3 missions, deterministic, never repeating within the set. */ | |
| export function missionSet(level) { | |
| const c = { s: seedOf(level * 104729 + 7) }; | |
| const out = []; | |
| while (out.length < 3) { const m = MISSIONS[Math.floor(rnd(c) * MISSIONS.length)]; if (!out.includes(m.id)) out.push(m.id); } | |
| return out; | |
| } | |
| export function missionById(id) { return MISSIONS.find((m) => m.id === id); } | |
| /** Apply one run to mission progress { level, ids, prog }. Returns { prog, done: [ids], levelUp }. */ | |
| export function applyMissions(state, stats) { | |
| const s = { level: state.level || 1, ids: state.ids && state.ids.length === 3 ? state.ids.slice() : missionSet(state.level || 1), prog: { ...(state.prog || {}) } }; | |
| const done = []; | |
| for (const id of s.ids) { | |
| const m = missionById(id); if (!m) continue; | |
| const before = s.prog[id] || 0; | |
| if (before >= m.goal) continue; | |
| const add = stats[m.stat] || 0; | |
| s.prog[id] = m.run ? Math.max(before, add) : before + add; | |
| if (s.prog[id] >= m.goal) { s.prog[id] = m.goal; done.push(id); } | |
| } | |
| let levelUp = false; | |
| if (s.ids.every((id) => (s.prog[id] || 0) >= (missionById(id) || { goal: 0 }).goal) && s.level < 30) { | |
| s.level += 1; s.ids = missionSet(s.level); s.prog = {}; levelUp = true; | |
| } | |
| return { state: s, done, levelUp }; | |
| } | |
| export const SLEDS = [ | |
| { id: 'plank', name: 'Pine Plank', cost: 0, color: '#C98A4B', accent: '#7A4A22' }, | |
| { id: 'comet', name: 'Red Comet', cost: 500, color: '#E8413C', accent: '#FFD24A' }, | |
| { id: 'toboggan', name: 'Frost Toboggan', cost: 1200, color: '#7FD6FF', accent: '#FFFFFF' }, | |
| { id: 'volt', name: 'Volt Board', cost: 2500, color: '#2B2F45', accent: '#4DF2FF' }, | |
| { id: 'saucer', name: 'Snowcat Saucer', cost: 4000, color: '#FF9F2E', accent: '#2B2F45' }, | |
| { id: 'aurora', name: 'Aurora Bob', cost: 7000, color: '#8E5CFF', accent: '#52FFB8' }, | |
| ]; | |
| export const RIDERS = [ | |
| { id: 'juno', name: 'Juno', cost: 0, coat: '#FF7A2E', hat: '#2E6BFF', skin: '#F2C29B' }, | |
| { id: 'bo', name: 'Bo', cost: 400, coat: '#3FA34D', hat: '#8B5A2B', skin: '#8D5B3C', ears: true }, | |
| { id: 'kiko', name: 'Kiko', cost: 900, coat: '#E84C8B', hat: '#2BD3C6', skin: '#F5D0B5' }, | |
| { id: 'marlo', name: 'Marlo', cost: 1800, coat: '#24304F', hat: '#FFCC33', skin: '#C68B5E', shades: true }, | |
| { id: 'tamsin', name: 'Tamsin', cost: 3000, coat: '#6C4BFF', hat: '#FFFFFF', skin: '#E9B892' }, | |
| { id: 'ozzy', name: 'Ozzy', cost: 5000, coat: '#E9F3FF', hat: '#9CC7FF', skin: '#F2D6BF', yeti: true }, | |
| ]; | |
| export const TRAILS = [ | |
| { id: 'powder', name: 'Powder', cost: 0, color: '#FFFFFF' }, | |
| { id: 'sparkle', name: 'Sparkle', cost: 300, color: '#FFE36B' }, | |
| { id: 'ember', name: 'Ember', cost: 800, color: '#FF6A3D' }, | |
| { id: 'leaf', name: 'Leaf', cost: 1500, color: '#6BE37A' }, | |
| { id: 'volt', name: 'Volt', cost: 2500, color: '#4DF2FF' }, | |
| { id: 'aurora', name: 'Aurora', cost: 4500, color: '#B07BFF' }, | |
| ]; | |
| // Looks that come only with a shop pack (web shop, shop.js; Roblox: the Aurora Garage pass gives the same Prism trio). | |
| // They are not bought with coins (cost -1) and, like every look, never change the ride. | |
| export const SHOP_LOOKS = { | |
| sleds: [ | |
| { id: 'kindle', name: 'Kindle Comet', cost: -1, pack: 'starter', color: '#FF4F1F', accent: '#FFE36B' }, | |
| { id: 'prism', name: 'Prism Bob', cost: -1, pack: 'aurora', color: '#FFFFFF', accent: '#FF5CCD' }, | |
| ], | |
| riders: [{ id: 'nova', name: 'Nova', cost: -1, pack: 'aurora', coat: '#141628', hat: '#FF5CCD', skin: '#E6BEA0', shades: true }], | |
| trails: [{ id: 'prism', name: 'Prism', cost: -1, pack: 'aurora', color: '#FF5CCD' }], | |
| }; | |
| /** Race coins for a place (1-based); every finisher gets at least 10. */ | |
| export function raceReward(place) { return place === 1 ? 60 : place === 2 ? 40 : place === 3 ? 25 : 10; } | |