// Wispmarch core: pure, deterministic rules (weapons, passives, meta shop, daily modifiers, save data). // No DOM here, so node --test can check it. (c) 2026 CyberMax. All rights reserved. import { hash, rng, dayNumber } from './cmx.js'; import { loadProg, defaultProg, loadStreak, defaultStreak, NIGHTS, loadTrials } from './levels.js'; export const GAME = 'wispmarch'; export const SAVE_KEY = 'wispmarch:save'; export const DAWN = 600; // standard run: dawn at 10:00 export const DAILY_LEN = 300; // daily run: 5:00 export const BOSS_AT = 480; // boss at 8:00 export const MAX_LV = 5; export const WEAPONS = { spark: { name: 'Spark Bolts', icon: '✦', desc: ['Fires a spark at the nearest gloom', '+1 bolt', 'Faster, +dmg', '+1 bolt, pierce', '+dmg, +speed'], evo: { with: 'haste', name: 'Starfall Volley' } }, firefly: { name: 'Orbit Fireflies', icon: '❋', desc: ['A firefly circles you', '+1 firefly', '+dmg, wider orbit', '+1 firefly', '+1 firefly, faster'], evo: { with: 'glow', name: 'Firefly Halo' } }, nova: { name: 'Lantern Pulse', icon: '◎', desc: ['A light pulse knocks gloom back', 'Bigger pulse', 'More often', '+dmg, bigger', 'Much more often'], evo: { with: 'ward', name: 'Sunrise Nova' } }, chain: { name: 'Will-o-Chain', icon: 'ϟ', desc: ['Lightning jumps between foes', '+2 jumps', '+dmg', '+2 jumps, faster', '+2 jumps, +dmg'], evo: { with: 'kindle', name: 'Storm Lantern' } }, ember: { name: 'Ember Trail', icon: '♨', desc: ['You leave burning embers', 'Embers last longer', 'Wider embers', '+dmg', 'Wider, longer'], evo: { with: 'drift', name: 'Wildfire Path' } }, moon: { name: 'Moonbeam', icon: '☾', desc: ['Moonlight strikes a random foe', '+1 beam', '+dmg, wider', '+1 beam', '+2 beams'], evo: { with: 'lure', name: 'Full Moon' } }, }; export const PASSIVES = { kindle: { name: 'Kindle', icon: '🔥', desc: '+12% damage' }, haste: { name: 'Haste', icon: '⏳', desc: '-8% weapon cooldown' }, glow: { name: 'Glow', icon: '☀', desc: '+12% area and light' }, drift: { name: 'Drift', icon: '➶', desc: '+8% move speed' }, lure: { name: 'Lure', icon: '🧲', desc: '+30% shard magnet' }, ward: { name: 'Ward', icon: '🛡', desc: '+20 max light, +regen' }, }; /** Weapon numbers for a level (1..5) and evolution flag. cd in seconds, dmg per hit, area multiplier etc. */ export function weaponStats(id, lv, evo = false) { const L = Math.max(1, Math.min(MAX_LV, lv)) - 1; const at = (arr) => arr[L]; switch (id) { case 'spark': return { cd: evo ? 0.32 : at([0.8, 0.8, 0.7, 0.65, 0.55]), dmg: (evo ? 30 : at([11, 11, 15, 15, 20])), count: evo ? 6 : at([1, 2, 2, 3, 3]), pierce: evo ? 3 : at([0, 0, 0, 1, 1]), speed: at([420, 420, 460, 460, 520]) }; case 'firefly': return { count: evo ? 8 : at([1, 2, 2, 3, 4]), dmg: evo ? 26 : at([9, 9, 13, 13, 16]), radius: evo ? 120 : at([68, 68, 82, 82, 90]), spin: evo ? 4.2 : at([3, 3, 3.2, 3.2, 3.8]) }; case 'nova': return { cd: evo ? 1.4 : at([3.2, 3.0, 2.5, 2.4, 1.9]), dmg: evo ? 60 : at([16, 18, 20, 30, 34]), radius: evo ? 230 : at([95, 115, 120, 145, 150]), knock: evo ? 340 : 240 }; case 'chain': return { cd: evo ? 0.8 : at([1.7, 1.6, 1.5, 1.3, 1.2]), dmg: evo ? 40 : at([15, 15, 21, 21, 27]), jumps: evo ? 14 : at([3, 5, 5, 7, 9]), range: 150 }; case 'ember': return { drop: evo ? 0.09 : 0.2, life: evo ? 3.6 : at([1.6, 2.2, 2.2, 2.4, 3]), radius: evo ? 34 : at([20, 20, 26, 26, 31]), dmg: evo ? 14 : at([5, 5, 6, 9, 10]) }; case 'moon': return { cd: evo ? 1.2 : at([2.6, 2.6, 2.4, 2.3, 2.0]), dmg: evo ? 90 : at([32, 32, 44, 44, 52]), count: evo ? 6 : at([1, 2, 2, 3, 5]), radius: evo ? 80 : at([44, 44, 56, 56, 62]) }; } return null; } /** Shards needed to go from level `lv` to `lv+1`. */ export function xpNeed(lv) { return Math.floor(2 + lv * 3 + Math.pow(lv, 1.9) * 0.5); } // ---------------------------------------------------------------- meta shop export const SHOP = [ { id: 'vigor', name: 'Vigor', icon: '❤', desc: '+12 max light (HP)', max: 5, base: 30 }, { id: 'swift', name: 'Swiftness', icon: '➶', desc: '+5% move speed', max: 5, base: 30 }, { id: 'magnet', name: 'Magnet', icon: '🧲', desc: '+20% shard reach', max: 5, base: 25 }, { id: 'might', name: 'Might', icon: '✦', desc: '+6% damage', max: 5, base: 40 }, { id: 'reroll', name: 'Reroll', icon: '↻', desc: '+1 card reroll per run', max: 3, base: 50 }, { id: 'revive', name: 'Second Wick', icon: '✚', desc: 'Revive once per run at half light', max: 1, base: 300 }, { id: 'choice', name: 'Wick Choice', icon: '☰', desc: 'Pick any unlocked starting weapon', max: 1, base: 150 }, ]; export function shopCost(id, lv) { const s = SHOP.find((x) => x.id === id); if (!s || lv >= s.max) return Infinity; return Math.round(s.base * Math.pow(lv + 1, 1.5) / 5) * 5; } export function buy(save, id) { const lv = save.up[id] || 0, c = shopCost(id, lv); if (c === Infinity || save.embers < c) return false; save.embers -= c; save.up[id] = lv + 1; return true; } /** Permanent bonuses from the shop, applied at run start. */ export function metaBonus(up = {}) { return { hp: 12 * (up.vigor || 0), speed: 1 + 0.05 * (up.swift || 0), magnet: 1 + 0.2 * (up.magnet || 0), dmg: 1 + 0.06 * (up.might || 0), rerolls: 1 + (up.reroll || 0), revives: up.revive || 0, choice: !!up.choice }; } // ---------------------------------------------------------------- wisps (characters) export const WISPS = [ { id: 'pip', name: 'Pip', weapon: 'spark', perk: 'Balanced', unlock: null, hint: 'Ready from the start' }, { id: 'moss', name: 'Moss', weapon: 'firefly', perk: '+10% max light', unlock: 'lv10', hint: 'Reach level 10 in a run' }, { id: 'cinder', name: 'Cinder', weapon: 'ember', perk: '+10% speed', unlock: 'kills1000', hint: 'Banish 1,000 gloom in total' }, { id: 'volt', name: 'Volt', weapon: 'chain', perk: '+10% cooldown', unlock: 'daily5', hint: 'Survive a full daily run' }, { id: 'vesper', name: 'Vesper', weapon: 'moon', perk: '+15% area', unlock: 'dawn', hint: 'Reach dawn in a standard run' }, ]; export const SKINS = [ { id: 'lantern', name: 'Lantern', core: '#FFE9A8', glow: '#FFC857', pass: false }, { id: 'aurora', name: 'Aurora', core: '#C9FFF1', glow: '#45E0B4', pass: true }, { id: 'rose', name: 'Ember Rose', core: '#FFD1DC', glow: '#FF5E8A', pass: true }, { id: 'frost', name: 'Frostlight', core: '#E3EEFF', glow: '#7DA8FF', pass: true }, ]; /** Achievements → unlock ids, given lifetime stats and the run just finished. */ export function checkUnlocks(save, run) { const got = []; const has = (k) => save.unlocked.includes(k); const add = (k) => { if (!has(k)) { save.unlocked.push(k); got.push(k); } }; if (run && run.level >= 10) add('lv10'); if (save.stats.kills >= 1000) add('kills1000'); if (run && run.mode === 'daily' && run.won) add('daily5'); if (run && run.mode === 'standard' && run.won) add('dawn'); return got; } // ---------------------------------------------------------------- daily challenge export const MODIFIERS = [ { id: 'swiftmoths', name: 'Swift Moths', desc: 'Moths fly 45% faster' }, { id: 'doubleshards', name: 'Double Shards', desc: 'Every shard counts twice' }, { id: 'glass', name: 'Glass Lantern', desc: 'Half light (HP), +50% damage' }, { id: 'fog', name: 'Thick Fog', desc: 'Your light reaches 30% less far' }, { id: 'embers', name: 'Ember Rain', desc: 'Start with Ember Trail too' }, { id: 'elites', name: 'Elite Night', desc: 'Elites every 40 s' }, ]; /** The daily rules (modifier + starting weapon) come only from the seed, so a shared ?seed= link replays them. */ export function seeded(seed) { const r = rng(seed >>> 0); const mod = MODIFIERS[Math.floor(r() * MODIFIERS.length)]; const weapon = ['spark', 'firefly', 'nova', 'chain', 'moon'][Math.floor(r() * 5)]; return { seed: seed >>> 0, mod, weapon }; } export function daily(iso) { const seed = hash(`wispmarch:${iso}`); return { iso, num: dayNumber(iso), ...seeded(seed) }; } export function dailyScore(time, kills, shards) { return Math.floor(time) * 10 + kills + shards; } /** Embers earned by a run. */ export function embersFor(run) { if (run.mode === 'night') return Math.floor(run.kills / 12 + run.time / 8 + run.level * 2 + (run.won ? 15 : 0) + 6 * (run.stars || 0) + (run.boss ? 20 : 0)); return Math.floor(run.kills / 12 + run.time / 8 + run.level * 2 + (run.won ? 60 : 0) + (run.mode === 'daily' ? 20 : 0) + (run.boss ? 40 : 0)); } // ---------------------------------------------------------------- save export const MODES = ['nights', 'trials', 'daily', 'standard', 'endless']; export function defaultSave() { return { v: 2, embers: 0, up: {}, unlocked: [], stats: { runs: 0, kills: 0, bestTime: 0, bestLevel: 0, wins: 0 }, daily: { iso: '', best: 0, tries: 0 }, wisp: 'pip', weapon: '', skin: 'lantern', mode: 'nights', mouse: false, nights: defaultProg(), streak: defaultStreak(), night: 1, trials: {}, trialDay: '' }; } export function loadSave(raw) { const d = defaultSave(); if (!raw || typeof raw !== 'object') return d; const n = (x, def = 0) => (Number.isFinite(x) && x >= 0 ? Math.floor(x) : def); d.embers = n(raw.embers); for (const s of SHOP) if (raw.up && raw.up[s.id]) d.up[s.id] = Math.min(s.max, n(raw.up[s.id])); if (Array.isArray(raw.unlocked)) d.unlocked = raw.unlocked.filter((x) => typeof x === 'string').slice(0, 20); if (raw.stats) for (const k of Object.keys(d.stats)) d.stats[k] = n(raw.stats[k]); if (raw.daily && typeof raw.daily.iso === 'string') d.daily = { iso: raw.daily.iso, best: n(raw.daily.best), tries: n(raw.daily.tries) }; if (WISPS.some((w) => w.id === raw.wisp)) d.wisp = raw.wisp; if (typeof raw.weapon === 'string' && WEAPONS[raw.weapon]) d.weapon = raw.weapon; if (SKINS.some((s) => s.id === raw.skin)) d.skin = raw.skin; if (MODES.includes(raw.mode)) d.mode = raw.mode; if (raw.v !== 2) d.mode = 'nights'; // v1 saves: show the new nights first (everything else is kept) d.mouse = !!raw.mouse; d.nights = loadProg(raw.nights); d.streak = loadStreak(raw.streak); if (Number.isInteger(raw.night) && raw.night >= 1 && raw.night <= NIGHTS.length) d.night = raw.night; d.trials = loadTrials(raw.trials); if (typeof raw.trialDay === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.trialDay)) d.trialDay = raw.trialDay; return d; } export const wispUnlocked = (save, w) => !w.unlock || save.unlocked.includes(w.unlock); export function fmtTime(s) { s = Math.max(0, Math.floor(s)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }