wispmarch / core.js
CyberMax-tools's picture
Deploy wispmarch
d3ead5a verified
Raw History Blame Contribute Delete
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')}`; }