Spaces:
Running
Running
File size: 14,338 Bytes
a2f5415 d3ead5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | // 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;
}
|