// 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=&score=&lvl= 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; }