Spaces:
Running
Running
Download levels.js from CyberMax-tools/wispmarch: direct link, hf CLI and curl.
- Browser
- Download file 14.3 kB
-
https://huggingface.co/spaces/CyberMax-tools/wispmarch/resolve/main/levels.js
- Command line
-
hf download hf://spaces/CyberMax-tools/wispmarch/levels.js
-
curl -L -o levels.js https://huggingface.co/spaces/CyberMax-tools/wispmarch/resolve/main/levels.js
14.3 kB
| // Wispmarch nights: 40 hand-tuned short levels (data table, not random), stars, progress save, streak and | |
| // "beat my score" links. Pure (no DOM) so node --test checks it. (c) 2026 CyberMax. All rights reserved. | |
| import { hash, dayNumber, dateOf } from './cmx.js'; | |
| export const FREE_NIGHTS = 20; // nights 1-20 are free for everyone; 21+ need the CyberMax Fun Pass | |
| export const NIGHT_XP = 1.5; // short nights level you up faster than the 10-minute run | |
| // A new twist every 5 nights; every 10th night is a challenge night. | |
| export const GROUPS = [ | |
| { from: 1, name: 'First Light', icon: '✦', twist: 'Moths, then crawlers' }, | |
| { from: 6, name: 'Wraith Reeds', icon: '👻', twist: 'Wraiths surge in waves' }, | |
| { from: 11, name: 'Elite Hours', icon: '👹', twist: 'Brutes stalk the reeds' }, | |
| { from: 16, name: 'Fog Banks', icon: '🌫', twist: 'Your light reaches less far' }, | |
| { from: 21, name: 'Moth Gale', icon: '🦋', twist: 'Moths fly 45% faster' }, | |
| { from: 26, name: 'Glass Lantern', icon: '💎', twist: 'Half light, +50% damage' }, | |
| { from: 31, name: "Herald's Court", icon: '👑', twist: 'Heralds hunt you mid-night' }, | |
| { from: 36, name: 'Darkest Hour', icon: '🌑', twist: 'Fog, fast moths and elites' }, | |
| ]; | |
| // Columns: name, seconds, mix [moth, crawler, wraith], on-screen target [start, end], spawns/s [start, end], | |
| // hp multiplier [start, end], modifiers, swarm times (s), elite every (s, 0 = none), boss [kind, at s, hp mult] or 0, | |
| // third-star kill target (or 'boss' = banish the boss). | |
| const H = 'herald', W = 'warden'; | |
| const ROWS = [ | |
| // 1-5 First Light | |
| ['First Spark', 45, [1, 0, 0], [14, 28], [1.4, 2.2], [1, 1.05], [], [], 0, 0, 60], | |
| ['Reed Path', 60, [1, 0, 0], [18, 38], [1.6, 3], [1, 1.15], [], [40], 0, 0, 110], | |
| ['Crawling Mud', 60, [3, 1, 0], [20, 40], [1.6, 3], [1, 1.2], [], [], 0, 0, 100], | |
| ['Lily Hollow', 75, [3, 1, 0], [22, 48], [1.8, 3.4], [1.05, 1.3], [], [50], 0, 0, 130], | |
| ['Brute Bridge', 90, [3, 1.4, 0], [24, 56], [2, 3.6], [1.1, 1.4], [], [40, 75], 0, [H, 60, 0.4], 150], | |
| // 6-10 Wraith Reeds | |
| ['Pale Shapes', 75, [3, 1, 0.8], [24, 50], [2, 3.4], [1.1, 1.35], [], [], 0, 0, 120], | |
| ['Whisper Marsh', 90, [3, 1.2, 1], [26, 56], [2, 3.8], [1.15, 1.45], [], [60], 0, 0, 130], | |
| ['Sunken Bells', 90, [2.5, 1.2, 1.4], [28, 60], [2.2, 4], [1.2, 1.55], [], [45], 0, 0, 150], | |
| ['Hollow Choir', 105, [2.5, 1.3, 1.6], [30, 64], [2.2, 4.2], [1.25, 1.65], [], [50, 90], 0, 0, 180], | |
| ['Moth Storm', 120, [1, 0, 0], [30, 70], [3, 5], [1.3, 1.7], ['swiftmoths'], [20, 40, 60, 80, 100], 0, 0, 450], | |
| // 11-15 Elite Hours | |
| ['Heavy Steps', 90, [3, 1.4, 1], [28, 58], [2.2, 4], [1.3, 1.65], [], [], 40, 0, 150], | |
| ['Iron Reeds', 105, [3, 1.5, 1.1], [30, 62], [2.2, 4.2], [1.35, 1.75], [], [60], 35, 0, 170], | |
| ['Bog Kings', 105, [2.6, 1.6, 1.2], [32, 66], [2.4, 4.4], [1.4, 1.85], [], [45, 85], 30, 0, 170], | |
| ['Toll of Horns', 120, [2.6, 1.6, 1.4], [34, 70], [2.4, 4.6], [1.45, 1.95], [], [70], 28, 0, 200], | |
| ['Gatekeeper', 120, [2.6, 1.6, 1.4], [34, 72], [2.5, 4.8], [1.5, 2], [], [50, 95], 32, [H, 80, 0.8], 'boss'], | |
| // 16-20 Fog Banks | |
| ['Grey Veil', 105, [3, 1.4, 1.2], [32, 64], [2.4, 4.4], [1.5, 1.95], ['fog'], [], 0, 0, 140], | |
| ['Low Mist', 105, [3, 1.5, 1.3], [34, 68], [2.4, 4.6], [1.55, 2.05], ['fog'], [60], 40, 0, 180], | |
| ['Blind Fen', 120, [2.6, 1.6, 1.5], [36, 72], [2.6, 4.8], [1.6, 2.15], ['fog'], [45, 90], 35, 0, 180], | |
| ['Shroud Walk', 120, [2.6, 1.7, 1.6], [38, 76], [2.6, 5], [1.65, 2.25], ['fog'], [60, 100], 30, 0, 190], | |
| ['Herald Hunt', 150, [2.6, 1.6, 1.4], [36, 76], [2.6, 5], [1.7, 2.3], ['fog'], [70, 120], 45, [H, 30, 1.4], 'boss'], | |
| // 21-25 Moth Gale (Fun Pass) | |
| ['Wingstorm', 120, [3.5, 1.2, 1], [36, 72], [2.8, 5.2], [1.6, 2.1], ['swiftmoths'], [50, 90], 0, 0, 220], | |
| ['Dust Wings', 120, [3.5, 1.3, 1.2], [38, 74], [2.8, 5.4], [1.65, 2.2], ['swiftmoths'], [40, 80], 40, 0, 210], | |
| ['Flutter Night', 135, [3.2, 1.4, 1.4], [38, 76], [3, 5.6], [1.65, 2.25], ['swiftmoths'], [45, 90, 120], 35, 0, 230], | |
| ['Gale Crossing', 135, [3.2, 1.5, 1.5], [40, 80], [3, 5.8], [1.7, 2.3], ['swiftmoths'], [60, 110], 30, 0, 240], | |
| ['Eye of Wings', 150, [3.2, 1.5, 1.5], [40, 82], [3, 6], [1.8, 2.4], ['swiftmoths'], [50, 100, 130], 30, [H, 90, 1.4], 'boss'], | |
| // 26-30 Glass Lantern | |
| ['Brittle Glow', 120, [3, 1.5, 1.2], [36, 72], [2.8, 5.2], [1.6, 2.1], ['glass'], [60], 0, 0, 180], | |
| ['Thin Flame', 120, [3, 1.5, 1.3], [38, 74], [2.8, 5.4], [1.65, 2.2], ['glass'], [45, 90], 40, 0, 200], | |
| ['Cracked Wick', 135, [2.8, 1.6, 1.5], [38, 76], [3, 5.6], [1.7, 2.3], ['glass', 'doubleshards'], [50, 100], 35, 0, 300], | |
| ['Shard Rain', 135, [2.8, 1.6, 1.6], [40, 78], [3, 5.8], [1.75, 2.35], ['glass', 'doubleshards'], [60, 110], 30, 0, 320], | |
| ["Warden's Wake", 180, [2.8, 1.6, 1.5], [40, 82], [3, 6], [1.8, 2.5], ['glass'], [60, 150], 40, [W, 60, 0.22], 'boss'], | |
| // 31-35 Herald's Court | |
| ['First Herald', 135, [3, 1.6, 1.4], [44, 86], [3, 5.6], [1.85, 2.5], [], [70], 40, [H, 45, 1.6], 220], | |
| ['Twin Banners', 150, [3, 1.6, 1.5], [46, 90], [3, 5.8], [1.9, 2.55], ['embers'], [50, 110], 35, [H, 60, 1.7], 260], | |
| ['Court of Ash', 150, [2.8, 1.7, 1.6], [48, 94], [3.2, 6], [1.95, 2.6], ['embers'], [45, 95, 130], 32, [H, 50, 1.8], 260], | |
| ['Crown of Reeds', 165, [2.8, 1.7, 1.7], [50, 98], [3.2, 6.2], [2, 2.7], [], [60, 120], 30, [H, 70, 2], 250], | |
| ['Throne Fen', 165, [2.8, 1.8, 1.8], [50, 100], [3.2, 6.4], [2, 2.7], [], [50, 100, 140], 30, [H, 40, 2], 'boss'], | |
| // 36-40 Darkest Hour | |
| ['Starless', 150, [3.2, 1.6, 1.5], [48, 94], [3.2, 6], [2, 2.7], ['fog', 'swiftmoths'], [60, 120], 35, 0, 230], | |
| ['Black Water', 150, [3.2, 1.7, 1.6], [50, 98], [3.2, 6.2], [2.05, 2.8], ['fog', 'swiftmoths'], [50, 100], 30, 0, 230], | |
| ['Last Candles', 165, [3, 1.8, 1.7], [52, 102], [3.4, 6.4], [2.1, 2.9], ['fog', 'swiftmoths'], [45, 95, 140], 28, 0, 250], | |
| ['Edge of Dawn', 165, [3, 1.8, 1.8], [54, 106], [3.4, 6.6], [2.15, 3], ['fog', 'swiftmoths'], [60, 120, 150], 26, [H, 90, 2.2], 270], | |
| ['The Longest Night', 240, [3, 1.8, 1.8], [52, 110], [3.4, 7], [2.2, 3.2], ['fog', 'swiftmoths'], [40, 90, 140, 190], 30, [W, 120, 0.35], 'boss'], | |
| ]; | |
| export const NIGHTS = ROWS.map(([name, dur, mix, spawn, rate, hp, mods, swarms, elite, boss, star3], i) => { | |
| const n = i + 1; | |
| return { | |
| n, name, dur, mix, spawn, rate, hp, mods, swarms, elite, | |
| boss: boss ? { kind: boss[0], at: boss[1], hp: boss[2] } : null, | |
| kills: typeof star3 === 'number' ? star3 : 0, | |
| star3: star3 === 'boss' ? 'boss' : 'kills', | |
| challenge: n % 10 === 0, | |
| pass: n > FREE_NIGHTS, | |
| }; | |
| }); | |
| export const MAX_STARS = NIGHTS.length * 3; | |
| export const groupOf = (n) => { let g = GROUPS[0]; for (const x of GROUPS) if (n >= x.from) g = x; return g; }; | |
| /** True when night n is the first of a new twist group (shown as "New twist" in game). */ | |
| export const isTwist = (n) => GROUPS.some((g) => g.from === n); | |
| /** Every night has a fixed seed, so everyone plays the same hand-tuned night. */ | |
| export const nightSeed = (n) => hash(`wispmarch:night:${n}`); | |
| /** The three star goals of a night, as short labels (the in-game goal line). */ | |
| export function starGoals(N) { | |
| return ['Reach dawn', 'Keep half your light', N.star3 === 'boss' ? `Banish the ${N.boss.kind === 'warden' ? 'Warden' : 'Herald'}` : `${N.kills} gloom banished`]; | |
| } | |
| /** Stars for a finished night: 1 = reached dawn, 2 = and at least half light left, 3 = and the kill/boss target. */ | |
| export function starsFor(N, r) { | |
| if (!N || !r || !r.won) return 0; | |
| let s = 1; | |
| if (r.hpFrac >= 0.5) s++; | |
| if (N.star3 === 'boss' ? r.boss : r.kills >= N.kills) s++; | |
| return s; | |
| } | |
| // ---------------------------------------------------------------- progress | |
| export function defaultProg() { return { stars: {}, best: 0, played: 0 }; } | |
| export function loadProg(raw) { | |
| const p = defaultProg(); | |
| if (!raw || typeof raw !== 'object') return p; | |
| const N = NIGHTS.length, int = (x) => (Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0); | |
| if (raw.stars && typeof raw.stars === 'object') { | |
| for (const k of Object.keys(raw.stars)) { const n = Number(k), s = int(raw.stars[k]); if (Number.isInteger(n) && n >= 1 && n <= N && s > 0) p.stars[n] = Math.min(3, s); } | |
| } | |
| p.best = Math.min(N, int(raw.best)); | |
| p.played = Math.min(N, int(raw.played)); | |
| for (const k in p.stars) p.best = Math.max(p.best, Number(k)); | |
| return p; | |
| } | |
| /** | |
| * Can night n be played? 'open' | 'locked' (clear the night before first) | 'pass' (needs the Fun Pass). | |
| * Never takes away a night already reached: any night up to the highest one ever started stays open, pass or not. | |
| */ | |
| export function access(n, prog, pro) { | |
| if (!Number.isInteger(n) || n < 1 || n > NIGHTS.length) return 'locked'; | |
| const reachable = n === 1 || n <= prog.played || !!prog.stars[n - 1]; | |
| if (!reachable) return 'locked'; | |
| if (n > FREE_NIGHTS && !pro && n > prog.played) return 'pass'; | |
| return 'open'; | |
| } | |
| /** Mark a night as started (only call when access() is 'open'). */ | |
| export function markPlayed(prog, n) { prog.played = Math.max(prog.played, n); return prog; } | |
| /** Save a finished night's stars; keeps the best. Returns { stars, gained, first }. */ | |
| export function recordNight(prog, n, stars) { | |
| const had = prog.stars[n] || 0; | |
| if (stars > had) prog.stars[n] = stars; | |
| if (stars > 0) prog.best = Math.max(prog.best, n); | |
| return { stars, gained: Math.max(0, stars - had), first: had === 0 && stars > 0 }; | |
| } | |
| export const totalStars = (prog) => Object.values(prog.stars).reduce((a, b) => a + b, 0); | |
| /** The night the Play button should start: the first open night not yet cleared, else the highest open night. */ | |
| export function nextNight(prog, pro) { | |
| let hi = 1; | |
| for (let n = 1; n <= NIGHTS.length; n++) { | |
| if (access(n, prog, pro) !== 'open') continue; | |
| if (!prog.stars[n]) return n; | |
| hi = n; | |
| } | |
| return hi; | |
| } | |
| // ---------------------------------------------------------------- streak (consecutive days with a finished run) | |
| export function defaultStreak() { return { n: 0, best: 0, last: '' }; } | |
| export function loadStreak(raw) { | |
| const s = defaultStreak(); | |
| if (!raw || typeof raw !== 'object') return s; | |
| const int = (x) => (Number.isFinite(x) && x >= 0 ? Math.min(100000, Math.floor(x)) : 0); | |
| s.n = int(raw.n); s.best = Math.max(int(raw.best), s.n); | |
| if (typeof raw.last === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.last)) s.last = raw.last; | |
| return s; | |
| } | |
| const prevDay = (iso) => dateOf(dayNumber(iso) - 1); | |
| /** Count today. Same day: no change. Day after the last one: +1. A gap: back to 1. */ | |
| export function bumpStreak(s, iso) { | |
| if (s.last === iso) return s; | |
| s.n = s.last && s.last === prevDay(iso) ? s.n + 1 : 1; | |
| s.last = iso; s.best = Math.max(s.best, s.n); | |
| return s; | |
| } | |
| /** The streak as it stands today (0 if the last played day is older than yesterday). */ | |
| export function liveStreak(s, iso) { return s.last === iso || (s.last && s.last === prevDay(iso)) ? s.n : 0; } | |
| // ---------------------------------------------------------------- "beat my score" links: ?seed=<n>&score=<n>&lvl=<n> | |
| export const MAX_SCORE = 99999999; | |
| /** Parse a share link's query. Only plain bounded integers are kept; anything else is ignored. null if nothing usable. */ | |
| export function parseLink(search) { | |
| let q; | |
| try { q = new URLSearchParams(typeof search === 'string' ? search : ''); } catch { return null; } | |
| const int = (k, lo, hi) => { | |
| const v = q.get(k); | |
| if (v == null || !/^\d{1,10}$/.test(v)) return null; | |
| const x = Number(v); | |
| return x >= lo && x <= hi ? x : null; | |
| }; | |
| const seed = int('seed', 0, 4294967295), score = int('score', 0, MAX_SCORE), lvl = int('lvl', 1, NIGHTS.length); | |
| if (seed === null && lvl === null) return null; | |
| return { seed, score, lvl }; | |
| } | |
| /** Build the query for a link (round-trips through parseLink). */ | |
| export function linkQuery({ seed = null, score = null, lvl = null } = {}) { | |
| const q = []; | |
| if (Number.isInteger(seed) && seed >= 0 && seed <= 4294967295) q.push(`seed=${seed}`); | |
| if (Number.isInteger(score) && score >= 0 && score <= MAX_SCORE) q.push(`score=${score}`); | |
| if (Number.isInteger(lvl) && lvl >= 1 && lvl <= NIGHTS.length) q.push(`lvl=${lvl}`); | |
| return q.length ? `?${q.join('&')}` : ''; | |
| } | |
| // ---------------------------------------------------------------- Trials (full-game mode, games lane GM 9 Oct 2026) | |
| // Replay any night you have cleared at Danger 1-3 (tougher gloom, denser swarms; fog at Danger 2, fast moths at 3). | |
| // Clearing a Danger level earns that night's crown for it (3 per night, 120 in all) and more Embers for the shop. | |
| export const DANGER = [ | |
| null, | |
| { name: 'Danger 1', hp: 1.35, rate: 1.2, crowd: 1.15, mods: [], embers: 1.5 }, | |
| { name: 'Danger 2', hp: 1.7, rate: 1.4, crowd: 1.3, mods: ['fog'], embers: 2 }, | |
| { name: 'Danger 3', hp: 2.1, rate: 1.6, crowd: 1.45, mods: ['fog', 'swiftmoths'], embers: 3 }, | |
| ]; | |
| export const MAX_DANGER = 3; | |
| export const MAX_CROWNS = NIGHTS.length * MAX_DANGER; | |
| /** Night N played at Danger d (a new object; the night table is never changed). */ | |
| export function trialNight(N, d) { | |
| const D = DANGER[d]; | |
| if (!N || !D) return N; | |
| const mul = (a, m) => [a[0] * m, a[1] * m]; | |
| return { ...N, hp: mul(N.hp, D.hp), rate: mul(N.rate, D.rate), spawn: mul(N.spawn, D.crowd), mods: [...new Set([...N.mods, ...D.mods])], danger: d, dangerName: D.name }; | |
| } | |
| /** Clean a saved trials map { night: highest danger cleared }. */ | |
| export function loadTrials(raw) { | |
| const t = {}; | |
| if (!raw || typeof raw !== 'object') return t; | |
| for (const k of Object.keys(raw)) { const n = Number(k), d = Math.floor(Number(raw[k])); if (Number.isInteger(n) && n >= 1 && n <= NIGHTS.length && d >= 1) t[n] = Math.min(MAX_DANGER, d); } | |
| return t; | |
| } | |
| export const crowns = (trials) => Object.values(trials).reduce((a, b) => a + b, 0); | |
| /** Trials open for night n: cleared at least once. Returns the next danger to try (1..3) or 0 when all are crowned. */ | |
| export function nextDanger(prog, trials, n) { return prog.stars[n] ? ((trials[n] || 0) < MAX_DANGER ? (trials[n] || 0) + 1 : MAX_DANGER) : 0; } | |
| /** The trial the Play button starts: the lowest cleared night with a danger level left, else the highest cleared night. */ | |
| export function nextTrial(prog, trials) { | |
| let hi = 0; | |
| for (let n = 1; n <= NIGHTS.length; n++) if (prog.stars[n]) { hi = n; if ((trials[n] || 0) < MAX_DANGER) return n; } | |
| return hi; | |
| } | |