cybermax / kenwren /levels.js
CyberMax-tools's picture
Deploy kenwren to the hub
f6d583b verified
Raw History Blame Contribute Delete
12.9 kB
// Kenwren solo mode: "Read a friend". Each level is a fictional friend with a clue card; guess how they would answer.
// Pure logic + data, no DOM (tested by tests/levels.test.mjs). Uses only the Classic questions from core.js, which are
// opinion/personality questions; the people and clues below are made up, so there are no facts to source.
// core.js is NOT changed by this file, so the toys Worker copy (kenwren-core.js) stays identical. (c) 2026 CyberMax.
import { BY_ID } from './core.js';
import { rng, hash, dayNumber } from './cmx.js';
export const FREE_MAX = 20; // levels 1-20 are free for everyone
export const SEED_MAX = 100000; // ?seed= range for "beat my score" links
export const DAILY_LEN = 6;
/** One clue line per option of every Classic question (same order as core.js opts). Each clue points at one answer. */
export const CLUES = {
c01: ['Has a group chat that plans every Saturday.', 'Owns a blanket with a pocket for the TV remote.', 'Collects a fridge magnet from every new town.', 'Spends weekends ticking off a to-do list.'],
c02: ['Grinds espresso beans before saying a word.', 'Keeps twelve kinds of loose-leaf in a drawer.', 'Latte at 8 AM, chamomile at 8 PM.', 'Hot drinks? Just a glass of water, thanks.'],
c03: ['Always has a crinkly bag of something salty.', 'Hides a cocoa bar in every coat pocket.', 'Brings an apple and a banana everywhere.', 'Calls cheddar a personality trait.'],
c04: ['Stares at birds with open jealousy.', 'Wishes they could vanish at every awkward party.', 'Hates commutes; wants to blink straight to the office.', 'Always wants to know what you are thinking.'],
c05: ['Up at 5 AM, smiling, already jogged.', 'Does their best work at 2 AM.', 'Disappears for a siesta at 3 PM.', 'Yawns at breakfast, lunch and dinner.'],
c06: ['Packs sunscreen and flip-flops first.', 'Owns hiking boots and a summit selfie.', 'Plans holidays around museums and skylines.', 'Has a 40-song driving playlist ready.'],
c07: ['Phone gallery: 900 photos of a tabby.', 'Stops to pet every pup on the street.', 'Home has a purring sofa and a barking doorbell.', 'Talks to a goldfish named Bubbles.'],
c08: ['Starts every party on the dance floor.', 'Ends every party in a two-hour kitchen chat.', 'Knows every late-night takeaway menu by heart.', 'Leaves every party before 11.'],
c09: ['Gets giddy when the first blossoms appear.', 'Lives for long, hot days.', 'Kicks piles of crunchy leaves on purpose.', 'Counts down to snow and scarves.'],
c10: ['Says fruit on pizza is not a crime.', 'Grabs the spicy salami slices first.', 'Orders anything with porcini on it.', 'Likes their pizza plain. Very plain.'],
c11: ['Carries two power banks, just in case.', 'Phone is never full, never empty.', 'Always asking: "Anyone got a charger?"', 'Calls you back from someone else\'s phone.'],
c12: ['Belts out ballads at every open mic.', 'Joins every flash-mob routine.', 'Always first to give the wedding toast.', 'Would skip any stage for a quiet night in.'],
c13: ['Makes the team plan and hands out the jobs.', 'Fills a whiteboard with wild ideas.', 'Quietly finishes everyone else\'s slides.', 'Group project? Last seen in week one.'],
c14: ['Rewatches the funniest films just to laugh again.', 'Watches scary movies with the lights off.', 'Cries at every on-screen wedding.', 'Loves car chases and explosions.'],
c15: ['Answers texts before you lock your phone.', 'Repliesโ€ฆ eventually. Like, next Thursday.', 'Sends four-minute audio messages.', 'Answers everything with ๐Ÿ‘ or ๐Ÿ™ƒ.'],
c16: ['Cooks eggs, toast and beans every morning.', 'Owns six boxes of flakes and hoops.', 'Skips food; one mug of coffee is breakfast.', 'Forgets to eat anything until lunch.'],
c17: ['Can quote every sitcom line.', 'Remembers every team\'s results from last season.', 'Names any song in two notes.', 'Full of trivia nobody asked for.'],
c18: ['Bed made, shelves dusted, socks sorted.', 'Messy desk, but knows where everything is.', 'Lost a shoe in their own bedroom. Twice.', 'Tidy on Monday, tornado by Friday.'],
c19: ['Checks the shower for eight-legged guests.', 'Won\'t go up the glass lookout tower.', 'Hands shake before any presentation.', 'Sleeps with a night light on.'],
c20: ['Has a world map pinned with 50 places to go.', 'Browses home listings for fun.', 'Has a resignation letter drafted, just in case.', 'Would win and keep it a total secret.'],
};
export const CLUE_QS = Object.keys(CLUES);
/** Fictional friends (one per level, in order). */
const CAST = [
['Mo', '๐ŸฆŠ'], ['Priya', '๐Ÿผ'], ['Jules', '๐Ÿธ'], ['Theo', '๐Ÿป'], ['Ana', '๐Ÿฆ‰'], ['Kai', '๐Ÿฏ'], ['Zara', '๐Ÿฆ„'], ['Leo', '๐Ÿฆ'], ['Nia', '๐Ÿจ'], ['Omar', '๐Ÿ™'],
['Ivy', '๐Ÿ'], ['Ravi', '๐Ÿง'], ['Luna', '๐Ÿฐ'], ['Finn', '๐Ÿณ'], ['Maya', '๐Ÿฆ‹'], ['Sol', '๐Ÿข'], ['Ezra', '๐Ÿฆ'], ['Rosa', '๐Ÿž'], ['Dev', '๐Ÿฒ'], ['Tess', '๐Ÿฆฉ'],
['Ari', '๐Ÿบ'], ['Bea', '๐Ÿฎ'], ['Cal', '๐Ÿฆ”'], ['Dani', '๐Ÿน'], ['Eli', '๐Ÿฆˆ'], ['Fay', '๐Ÿฆš'], ['Gus', '๐Ÿ—'], ['Hana', '๐Ÿฑ'], ['Ike', '๐Ÿฆ–'], ['Jo', '๐Ÿถ'],
['Kit', '๐Ÿฆฆ'], ['Lou', '๐Ÿฆฅ'], ['Mei', '๐Ÿฟ๏ธ'], ['Ned', '๐Ÿฆฌ'], ['Ola', '๐Ÿฆœ'], ['Pip', '๐Ÿฃ'], ['Quin', '๐Ÿฆญ'], ['Remy', '๐Ÿญ'], ['Suki', '๐Ÿฆข'], ['Tomi', '๐Ÿ‰'],
];
/** The four solo packs: each adds one twist; every 10th level is a challenge level. */
export const SOLO_PACKS = [
{ id: 'first', name: 'First Impressions', from: 1, to: 10, twist: 'Clues stay on screen.' },
{ id: 'chat', name: 'Group Chat', from: 11, to: 20, twist: 'Extra clues about other things mixed in.' },
{ id: 'memory', name: 'Memory Lane', from: 21, to: 30, twist: 'The clue card hides after a few seconds.' },
{ id: 'mind', name: 'Mind Reader', from: 31, to: 40, twist: 'Hidden card and a clock on every question.' },
];
// Hand-tuned table: qn = questions, order = clue order matches questions ('in') or is shuffled, noise = extra
// off-topic clues, peek = seconds the card stays visible (0 = always), timer = seconds per question (0 = none).
const T = [
/* 1-5 */ [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [4, 'in', 0, 0, 0], [4, 'in', 0, 0, 0],
/* 6-9 */ [4, 'mix', 0, 0, 0], [4, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0],
/* 10 */ [6, 'mix', 0, 0, 0],
/* 11-15 */ [4, 'mix', 1, 0, 0], [4, 'mix', 2, 0, 0], [5, 'mix', 1, 0, 0], [5, 'mix', 2, 0, 0], [5, 'mix', 2, 0, 0],
/* 16-19 */ [6, 'mix', 2, 0, 0], [6, 'mix', 3, 0, 0], [6, 'mix', 3, 0, 0], [7, 'mix', 3, 0, 0],
/* 20 */ [8, 'mix', 4, 0, 0],
/* 21-25 */ [3, 'in', 0, 10, 0], [4, 'in', 0, 10, 0], [4, 'mix', 0, 10, 0], [5, 'mix', 0, 12, 0], [5, 'mix', 1, 12, 0],
/* 26-29 */ [5, 'mix', 1, 9, 0], [6, 'mix', 1, 12, 0], [6, 'mix', 2, 11, 0], [6, 'mix', 2, 9, 0],
/* 30 */ [8, 'mix', 2, 14, 0],
/* 31-35 */ [4, 'mix', 0, 10, 8], [4, 'mix', 1, 10, 7], [5, 'mix', 1, 11, 7], [5, 'mix', 1, 10, 6], [6, 'mix', 1, 12, 6],
/* 36-39 */ [6, 'mix', 2, 11, 6], [6, 'mix', 2, 10, 5], [7, 'mix', 2, 12, 5], [7, 'mix', 3, 12, 5],
/* 40 */ [10, 'mix', 2, 16, 6],
];
export const LEVELS = T.map(([qn, order, noise, peek, timer], k) => {
const n = k + 1;
return { n, who: CAST[k][0], emoji: CAST[k][1], qn, order, noise, peek, timer, challenge: n % 10 === 0, pack: SOLO_PACKS.find((p) => n >= p.from && n <= p.to).id };
});
function shuffle(arr, r) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
/** Build a round from a seed string: questions (with the friend's answers) and the clue card. Same seed, same round. */
function build(seedStr, who, emoji, qn, order, noise) {
const r = rng(hash(seedStr));
let qs = shuffle(CLUE_QS, r);
// Never pair the two coffee questions (a "tea" friend whose breakfast is "just coffee" would read as a contradiction).
const pair = { c02: 'c16', c16: 'c02' };
qs = qs.filter((q, i) => !(pair[q] && qs.indexOf(pair[q]) < i));
const items = qs.slice(0, qn).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length) }));
const extra = qs.slice(qn, qn + noise).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length), noise: true }));
let card = [...items, ...extra].map((x) => ({ text: CLUES[x.q][x.a], q: x.q, noise: !!x.noise }));
if (order !== 'in') card = shuffle(card, r);
return { who, emoji, items, card };
}
export function buildLevel(n) {
const L = LEVELS[n - 1];
if (!L) return null;
return { ...L, kind: 'level', ...build(`kenwren-level-${n}`, L.who, L.emoji, L.qn, L.order, L.noise) };
}
/** "Daily read" / challenge round from a numeric seed (the daily uses today's puzzle number). */
export function buildRead(seed) {
const r = rng(hash(`kenwren-read-${seed}`));
const [who, emoji] = CAST[Math.floor(r() * CAST.length)];
return { n: seed, kind: 'read', who, emoji, qn: DAILY_LEN, order: 'mix', noise: 1, peek: 12, timer: 0, challenge: false, ...build(`kenwren-read-${seed}`, who, emoji, DAILY_LEN, 'mix', 1) };
}
export const dailySeed = (iso) => dayNumber(iso);
/** Stars: 3 = all right, 2 = at most one miss (on 4+ questions), 1 = at least 60% right, 0 = not cleared. */
export function starsFor(right, qn) {
if (!(qn > 0) || !(right >= 0)) return 0;
const wrong = qn - right;
if (wrong <= 0) return 3;
if (wrong === 1 && qn >= 4) return 2;
return right >= Math.ceil(qn * 0.6) ? 1 : 0;
}
/** Saved progress, sanitised: { stars: { [n]: 1..3 }, reached: highest level ever started }. */
export function cleanProgress(raw) {
const out = { stars: {}, reached: 1 };
if (!raw || typeof raw !== 'object') return out;
for (const [k, v] of Object.entries(raw.stars || {})) {
const n = Number(k);
if (Number.isInteger(n) && n >= 1 && n <= LEVELS.length && Number.isInteger(v) && v >= 1 && v <= 3) out.stars[n] = v;
}
if (Number.isInteger(raw.reached) && raw.reached >= 1 && raw.reached <= LEVELS.length) out.reached = raw.reached;
return out;
}
/** Can level n be played? 'ok' | 'progress' (clear the one before first) | 'pass' (Fun Pass level).
* Never takes a level away: anything up to the highest level already reached stays open, pass or not. */
export function access(n, prog, pro) {
if (!Number.isInteger(n) || n < 1 || n > LEVELS.length) return 'progress';
if (n <= prog.reached) return 'ok';
if (n > FREE_MAX && !pro) return 'pass';
if (n > 1 && !(prog.stars[n - 1] > 0)) return 'progress';
return 'ok';
}
/** Mark level n as started (call only when access() said 'ok'). */
export function start(prog, n) { return { stars: { ...prog.stars }, reached: Math.max(prog.reached, n) }; }
/** Record a finished level: keeps the best stars ever. Clearing does not open a pass level by itself. */
export function record(prog, n, right, qn) {
const s = starsFor(right, qn);
const stars = { ...prog.stars };
if (s > (stars[n] || 0)) stars[n] = s;
return { stars, reached: Math.max(prog.reached, n), got: s };
}
export const totalStars = (prog) => Object.values(prog.stars).reduce((a, b) => a + b, 0);
/** The next level to play: the first not yet cleared that is open, else the last open one. */
export function nextLevel(prog, pro) {
for (const L of LEVELS) if (!(prog.stars[L.n] > 0)) return access(L.n, prog, pro) === 'ok' ? L.n : Math.max(1, L.n - 1);
return LEVELS.length;
}
/** Daily streak: consecutive local days with a daily read finished. */
export function streak(prev, iso) {
const d = dayNumber(iso);
const p = prev && Number.isInteger(prev.day) && Number.isInteger(prev.n) ? prev : null;
if (p && p.day === d) return p;
return { day: d, n: p && p.day === d - 1 ? p.n + 1 : 1, best: Math.max(p?.best || 0, p && p.day === d - 1 ? p.n + 1 : 1) };
}
export const streakNow = (s, iso) => (s && Number.isInteger(s.day) && s.day >= dayNumber(iso) - 1 ? s.n : 0);
const INT = /^\d{1,6}$/;
/** Parse "beat my score" links: ?lvl=<1..40>&score=<0..qn> or ?seed=<1..100000>&score=<0..6>. Garbage -> null. */
export function parseLink(search) {
let p;
try { p = new URLSearchParams(search); } catch { return null; }
const num = (k) => (INT.test(p.get(k) || '') ? Number(p.get(k)) : null);
const lvl = num('lvl'); const seed = num('seed'); const sc = num('score');
if (p.has('lvl')) {
if (lvl === null || lvl < 1 || lvl > LEVELS.length) return null;
const qn = LEVELS[lvl - 1].qn;
return { lvl, score: sc !== null && sc <= qn ? sc : null };
}
if (p.has('seed')) {
if (seed === null || seed < 1 || seed > SEED_MAX) return null;
return { seed, score: sc !== null && sc <= DAILY_LEN ? sc : null };
}
return null;
}
export function linkQuery(o) {
if (o.lvl) return `?lvl=${o.lvl}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
return `?seed=${o.seed}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
}
/** Emoji result line for sharing. */
export const grid = (marks) => marks.map((m) => (m ? '๐ŸŸฉ' : '๐ŸŸฅ')).join('');