Spaces:
Running
Running
File size: 12,912 Bytes
f6d583b | 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 | // 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('');
|