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('');