Spaces:
Running
Running
Download core.js from CyberMax-tools/wispmarch: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/CyberMax-tools/wispmarch/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/wispmarch/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/wispmarch/resolve/main/core.js
10.6 kB
| // 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')}`; } | |