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;
}