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