@dupecom/botcha-cloudflare 0.3.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,10 +69,10 @@ async function deleteChallenge(kv, id) {
69
69
  }
70
70
  // ============ SPEED CHALLENGE ============
71
71
  /**
72
- * Generate a speed challenge: 5 SHA256 problems, 500ms to solve ALL
72
+ * Generate a speed challenge: 5 SHA256 problems, RTT-aware timeout
73
73
  * Trivial for AI, impossible for humans to copy-paste fast enough
74
74
  */
75
- export async function generateSpeedChallenge(kv) {
75
+ export async function generateSpeedChallenge(kv, clientTimestamp, app_id) {
76
76
  cleanExpired();
77
77
  const id = uuid();
78
78
  const problems = [];
@@ -82,25 +82,57 @@ export async function generateSpeedChallenge(kv) {
82
82
  problems.push({ num, operation: 'sha256_first8' });
83
83
  expectedAnswers.push(await sha256First(num.toString(), 8));
84
84
  }
85
- const timeLimit = 500;
85
+ // RTT-aware timeout calculation
86
+ const baseTimeLimit = 500; // Base computation time for AI agents
87
+ const MAX_RTT_MS = 5000; // Cap RTT to prevent timestamp spoofing (5s max)
88
+ const MAX_TIMESTAMP_AGE_MS = 30000; // Reject timestamps older than 30s
89
+ const now = Date.now();
90
+ let rttMs = 0;
91
+ let adjustedTimeLimit = baseTimeLimit;
92
+ let rttInfo = undefined;
93
+ if (clientTimestamp && clientTimestamp > 0) {
94
+ // Reject timestamps in the future or too far in the past (anti-spoofing)
95
+ const age = now - clientTimestamp;
96
+ if (age >= 0 && age <= MAX_TIMESTAMP_AGE_MS) {
97
+ // Calculate RTT from client timestamp, capped to prevent abuse
98
+ rttMs = Math.min(age, MAX_RTT_MS);
99
+ // Adjust timeout: base + (2 * RTT) + 100ms buffer
100
+ // The 2x RTT accounts for request + response network time
101
+ adjustedTimeLimit = Math.max(baseTimeLimit, baseTimeLimit + (2 * rttMs) + 100);
102
+ rttInfo = {
103
+ measuredRtt: rttMs,
104
+ adjustedTimeout: adjustedTimeLimit,
105
+ explanation: `RTT: ${rttMs}ms → Timeout: ${baseTimeLimit}ms + (2×${rttMs}ms) + 100ms = ${adjustedTimeLimit}ms`,
106
+ };
107
+ }
108
+ // else: invalid timestamp silently ignored, use base timeout
109
+ }
86
110
  const challenge = {
87
111
  id,
88
112
  problems,
89
113
  expectedAnswers,
90
- issuedAt: Date.now(),
91
- expiresAt: Date.now() + timeLimit + 100, // tiny grace
114
+ issuedAt: now,
115
+ expiresAt: now + adjustedTimeLimit + 50, // Small server-side grace period
116
+ baseTimeLimit,
117
+ adjustedTimeLimit,
118
+ rttMs,
119
+ app_id,
92
120
  };
93
121
  // Store in KV with 5 minute TTL (safety buffer for time checks)
94
122
  await storeChallenge(kv, id, challenge, 300);
123
+ const instructions = rttMs > 0
124
+ ? `Compute SHA256 of each number, return first 8 hex chars of each. Submit as array. You have ${adjustedTimeLimit}ms (adjusted for your ${rttMs}ms network latency).`
125
+ : 'Compute SHA256 of each number, return first 8 hex chars of each. Submit as array. You have 500ms.';
95
126
  return {
96
127
  id,
97
128
  problems,
98
- timeLimit,
99
- instructions: 'Compute SHA256 of each number, return first 8 hex chars of each. Submit as array. You have 500ms.',
129
+ timeLimit: adjustedTimeLimit,
130
+ instructions,
131
+ rttInfo,
100
132
  };
101
133
  }
102
134
  /**
103
- * Verify a speed challenge response
135
+ * Verify a speed challenge response with RTT-aware timeout
104
136
  */
105
137
  export async function verifySpeedChallenge(id, answers, kv) {
106
138
  const challenge = await getChallenge(kv, id, true);
@@ -111,8 +143,21 @@ export async function verifySpeedChallenge(id, answers, kv) {
111
143
  const solveTimeMs = now - challenge.issuedAt;
112
144
  // Delete challenge immediately to prevent replay attacks
113
145
  await deleteChallenge(kv, id);
146
+ // Use the challenge's adjusted timeout, fallback to base if not available
147
+ const timeLimit = challenge.adjustedTimeLimit || challenge.baseTimeLimit || 500;
114
148
  if (now > challenge.expiresAt) {
115
- return { valid: false, reason: `Too slow! Took ${solveTimeMs}ms, limit was 500ms` };
149
+ const rttExplanation = challenge.rttMs
150
+ ? ` (RTT-adjusted: ${challenge.rttMs}ms network + ${challenge.baseTimeLimit}ms compute = ${timeLimit}ms limit)`
151
+ : '';
152
+ return {
153
+ valid: false,
154
+ reason: `Too slow! Took ${solveTimeMs}ms, limit was ${timeLimit}ms${rttExplanation}`,
155
+ rttInfo: challenge.rttMs ? {
156
+ measuredRtt: challenge.rttMs,
157
+ adjustedTimeout: timeLimit,
158
+ actualTime: solveTimeMs,
159
+ } : undefined,
160
+ };
116
161
  }
117
162
  if (!Array.isArray(answers) || answers.length !== 5) {
118
163
  return { valid: false, reason: 'Must provide exactly 5 answers as array' };
@@ -122,7 +167,16 @@ export async function verifySpeedChallenge(id, answers, kv) {
122
167
  return { valid: false, reason: `Wrong answer for challenge ${i + 1}` };
123
168
  }
124
169
  }
125
- return { valid: true, solveTimeMs };
170
+ return {
171
+ valid: true,
172
+ solveTimeMs,
173
+ app_id: challenge.app_id,
174
+ rttInfo: challenge.rttMs ? {
175
+ measuredRtt: challenge.rttMs,
176
+ adjustedTimeout: timeLimit,
177
+ actualTime: solveTimeMs,
178
+ } : undefined,
179
+ };
126
180
  }
127
181
  // ============ STANDARD CHALLENGE ============
128
182
  const DIFFICULTY_CONFIG = {
@@ -133,28 +187,31 @@ const DIFFICULTY_CONFIG = {
133
187
  /**
134
188
  * Generate a standard challenge: compute SHA256 of concatenated primes
135
189
  */
136
- export async function generateStandardChallenge(difficulty = 'medium', kv) {
190
+ export async function generateStandardChallenge(difficulty = 'medium', kv, app_id) {
137
191
  cleanExpired();
138
192
  const id = uuid();
139
193
  const config = DIFFICULTY_CONFIG[difficulty];
194
+ // Random salt makes each challenge unique — precomputed lookup tables won't work
195
+ const salt = uuid().replace(/-/g, '').substring(0, 16);
140
196
  const primes = generatePrimes(config.primes);
141
- const concatenated = primes.join('');
197
+ const concatenated = primes.join('') + salt;
142
198
  const hash = await sha256(concatenated);
143
199
  const answer = hash.substring(0, 16);
144
200
  const challenge = {
145
201
  id,
146
- puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators). Return the first 16 hex characters.`,
202
+ puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators) followed by the salt "${salt}". Return the first 16 hex characters.`,
147
203
  expectedAnswer: answer,
148
204
  expiresAt: Date.now() + config.timeLimit + 1000,
149
205
  difficulty,
206
+ app_id,
150
207
  };
151
208
  // Store in KV with 5 minute TTL
152
209
  await storeChallenge(kv, id, challenge, 300);
153
210
  return {
154
211
  id,
155
- puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators). Return the first 16 hex characters.`,
212
+ puzzle: `Compute SHA256 of the first ${config.primes} prime numbers concatenated (no separators) followed by the salt "${salt}". Return the first 16 hex characters.`,
156
213
  timeLimit: config.timeLimit,
157
- hint: `Example: First 5 primes = "235711" → SHA256 → first 16 chars`,
214
+ hint: `Example: First 5 primes + salt = "235711${salt}" → SHA256 → first 16 chars`,
158
215
  };
159
216
  }
160
217
  /**
@@ -186,20 +243,22 @@ const landingTokens = new Map();
186
243
  */
187
244
  export async function verifyLandingChallenge(answer, timestamp, kv) {
188
245
  cleanExpired();
189
- // Verify timestamp is recent (within 5 minutes)
246
+ // Verify timestamp is recent (within 2 minutes — tighter window for security)
190
247
  const submittedTime = new Date(timestamp).getTime();
191
248
  const now = Date.now();
192
- if (Math.abs(now - submittedTime) > 5 * 60 * 1000) {
193
- return { valid: false, error: 'Timestamp too old or in future' };
249
+ if (Number.isNaN(submittedTime) || Math.abs(now - submittedTime) > 2 * 60 * 1000) {
250
+ return { valid: false, error: 'Timestamp expired or invalid. Request a fresh challenge.' };
194
251
  }
195
- // Calculate expected answer for today
252
+ // Per-request nonce: include the timestamp in the hash input so answers are unique per request
253
+ // This prevents answer sharing — each timestamp produces a different expected answer
196
254
  const today = new Date().toISOString().split('T')[0];
197
- const expectedHash = (await sha256(`BOTCHA-LANDING-${today}`)).substring(0, 16);
255
+ const expectedHash = (await sha256(`BOTCHA-LANDING-${today}-${timestamp}`)).substring(0, 16);
198
256
  if (answer.toLowerCase() !== expectedHash.toLowerCase()) {
199
257
  return {
200
258
  valid: false,
201
259
  error: 'Incorrect answer',
202
- hint: `Expected SHA256('BOTCHA-LANDING-${today}') first 16 chars`
260
+ // Don't leak the formula — only give a generic hint
261
+ hint: 'Parse the challenge from <script type="application/botcha+json"> on the landing page and compute the answer.',
203
262
  };
204
263
  }
205
264
  // Generate token
@@ -249,149 +308,490 @@ export async function solveSpeedChallenge(problems) {
249
308
  // ============ REASONING CHALLENGE ============
250
309
  // In-memory storage for reasoning challenges
251
310
  const reasoningChallenges = new Map();
252
- // Question bank - LLMs can answer these, simple scripts cannot
253
- const QUESTION_BANK = [
254
- // Analogies
255
- {
256
- id: 'analogy-1',
257
- question: 'Complete the analogy: Book is to library as car is to ___',
258
- category: 'analogy',
259
- acceptedAnswers: ['garage', 'parking lot', 'dealership', 'parking garage', 'lot'],
260
- },
261
- {
262
- id: 'analogy-2',
263
- question: 'Complete the analogy: Painter is to brush as writer is to ___',
264
- category: 'analogy',
265
- acceptedAnswers: ['pen', 'pencil', 'keyboard', 'typewriter', 'quill'],
266
- },
267
- {
268
- id: 'analogy-3',
269
- question: 'Complete the analogy: Fish is to water as bird is to ___',
270
- category: 'analogy',
271
- acceptedAnswers: ['air', 'sky', 'atmosphere'],
272
- },
273
- {
274
- id: 'analogy-4',
275
- question: 'Complete the analogy: Eye is to see as ear is to ___',
276
- category: 'analogy',
277
- acceptedAnswers: ['hear', 'listen', 'hearing', 'listening'],
278
- },
279
- // Wordplay
280
- {
281
- id: 'wordplay-1',
311
+ // ============ PARAMETERIZED QUESTION GENERATORS ============
312
+ // These generate unique questions each time, so a static lookup table won't work.
313
+ function randInt(min, max) {
314
+ return Math.floor(Math.random() * (max - min + 1)) + min;
315
+ }
316
+ function pickRandom(arr) {
317
+ return arr[Math.floor(Math.random() * arr.length)];
318
+ }
319
+ // --- Math generators (randomized numbers each time) ---
320
+ function genMathAdd() {
321
+ const a = randInt(100, 999);
322
+ const b = randInt(100, 999);
323
+ return {
324
+ id: `math-add-${uuid().substring(0, 8)}`,
325
+ question: `What is ${a} + ${b}?`,
326
+ category: 'math',
327
+ acceptedAnswers: [(a + b).toString()],
328
+ };
329
+ }
330
+ function genMathMultiply() {
331
+ const a = randInt(12, 99);
332
+ const b = randInt(12, 99);
333
+ return {
334
+ id: `math-mul-${uuid().substring(0, 8)}`,
335
+ question: `What is ${a} × ${b}?`,
336
+ category: 'math',
337
+ acceptedAnswers: [(a * b).toString()],
338
+ };
339
+ }
340
+ function genMathModulo() {
341
+ const a = randInt(50, 999);
342
+ const b = randInt(3, 19);
343
+ return {
344
+ id: `math-mod-${uuid().substring(0, 8)}`,
345
+ question: `What is ${a} % ${b} (modulo)?`,
346
+ category: 'math',
347
+ acceptedAnswers: [(a % b).toString()],
348
+ };
349
+ }
350
+ function genMathSheep() {
351
+ const total = randInt(15, 50);
352
+ const remaining = randInt(3, total - 2);
353
+ return {
354
+ id: `math-sheep-${uuid().substring(0, 8)}`,
355
+ question: `A farmer has ${total} sheep. All but ${remaining} run away. How many sheep does he have left? Answer with just the number.`,
356
+ category: 'math',
357
+ acceptedAnswers: [remaining.toString()],
358
+ };
359
+ }
360
+ function genMathDoubling() {
361
+ const days = randInt(20, 60);
362
+ return {
363
+ id: `math-double-${uuid().substring(0, 8)}`,
364
+ question: `A patch of lily pads doubles in size every day. If it takes ${days} days to cover the entire lake, how many days to cover half? Answer with just the number.`,
365
+ category: 'math',
366
+ acceptedAnswers: [(days - 1).toString()],
367
+ };
368
+ }
369
+ function genMathMachines() {
370
+ const n = pickRandom([5, 7, 8, 10, 12]);
371
+ const m = randInt(50, 200);
372
+ return {
373
+ id: `math-machines-${uuid().substring(0, 8)}`,
374
+ question: `If it takes ${n} machines ${n} minutes to make ${n} widgets, how many minutes would it take ${m} machines to make ${m} widgets? Answer with just the number.`,
375
+ category: 'math',
376
+ acceptedAnswers: [n.toString()],
377
+ };
378
+ }
379
+ // --- Code generators (randomized values) ---
380
+ function genCodeModulo() {
381
+ const a = randInt(20, 200);
382
+ const b = randInt(3, 15);
383
+ return {
384
+ id: `code-mod-${uuid().substring(0, 8)}`,
385
+ question: `In most programming languages, what does ${a} % ${b} evaluate to?`,
386
+ category: 'code',
387
+ acceptedAnswers: [(a % b).toString()],
388
+ };
389
+ }
390
+ function genCodeBitwise() {
391
+ const a = randInt(1, 31);
392
+ const b = randInt(1, 31);
393
+ const op = pickRandom(['&', '|', '^']);
394
+ const opName = op === '&' ? 'AND' : op === '|' ? 'OR' : 'XOR';
395
+ let answer;
396
+ if (op === '&')
397
+ answer = a & b;
398
+ else if (op === '|')
399
+ answer = a | b;
400
+ else
401
+ answer = a ^ b;
402
+ return {
403
+ id: `code-bit-${uuid().substring(0, 8)}`,
404
+ question: `What is ${a} ${op} ${b} (bitwise ${opName})? Answer with just the number.`,
405
+ category: 'code',
406
+ acceptedAnswers: [answer.toString()],
407
+ };
408
+ }
409
+ function genCodeStringLen() {
410
+ // Generate random alphanumeric strings of varying lengths (3-20 chars)
411
+ // This creates effectively infinite answer space (18 possible lengths × countless string combinations)
412
+ const length = randInt(3, 20);
413
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
414
+ let word = '';
415
+ for (let i = 0; i < length; i++) {
416
+ word += chars[Math.floor(Math.random() * chars.length)];
417
+ }
418
+ return {
419
+ id: `code-strlen-${uuid().substring(0, 8)}`,
420
+ question: `What is the length of the string "${word}"? Answer with just the number.`,
421
+ category: 'code',
422
+ acceptedAnswers: [word.length.toString()],
423
+ };
424
+ }
425
+ // --- Logic generators (randomized names/items) ---
426
+ function genLogicSyllogism() {
427
+ const groups = [
428
+ ['Bloops', 'Razzies', 'Lazzies'],
429
+ ['Florps', 'Zinkies', 'Mopples'],
430
+ ['Grunts', 'Tazzles', 'Wibbles'],
431
+ ['Plonks', 'Snazzles', 'Krinkles'],
432
+ ['Dweems', 'Fozzits', 'Glimmers'],
433
+ ];
434
+ const [a, b, c] = pickRandom(groups);
435
+ return {
436
+ id: `logic-syl-${uuid().substring(0, 8)}`,
437
+ question: `If all ${a} are ${b} and all ${b} are ${c}, are all ${a} definitely ${c}? Answer yes or no.`,
438
+ category: 'logic',
439
+ acceptedAnswers: ['yes'],
440
+ };
441
+ }
442
+ function genLogicNegation() {
443
+ const total = randInt(20, 100);
444
+ const keep = randInt(3, total - 5);
445
+ return {
446
+ id: `logic-neg-${uuid().substring(0, 8)}`,
447
+ question: `There are ${total} marbles in a bag. You remove all but ${keep}. How many marbles are left in the bag? Answer with just the number.`,
448
+ category: 'logic',
449
+ acceptedAnswers: [keep.toString()],
450
+ };
451
+ }
452
+ function genLogicSequence() {
453
+ const start = randInt(2, 20);
454
+ const step = randInt(2, 8);
455
+ const seq = [start, start + step, start + 2 * step, start + 3 * step];
456
+ return {
457
+ id: `logic-seq-${uuid().substring(0, 8)}`,
458
+ question: `What comes next in the sequence: ${seq.join(', ')}, ___? Answer with just the number.`,
459
+ category: 'logic',
460
+ acceptedAnswers: [(start + 4 * step).toString()],
461
+ };
462
+ }
463
+ // --- Wordplay / static (with randomized IDs so lookup by ID fails) ---
464
+ const WORDPLAY_GENERATORS = [
465
+ // Connection riddles (original + new)
466
+ () => ({
467
+ id: `wp-${uuid().substring(0, 8)}`,
282
468
  question: 'What single word connects: apple, Newton, gravity?',
283
469
  category: 'wordplay',
284
470
  acceptedAnswers: ['tree', 'fall', 'falling'],
285
- },
286
- {
287
- id: 'wordplay-2',
471
+ }),
472
+ () => ({
473
+ id: `wp-${uuid().substring(0, 8)}`,
288
474
  question: 'What single word connects: key, piano, computer?',
289
475
  category: 'wordplay',
290
476
  acceptedAnswers: ['keyboard', 'board', 'keys'],
291
- },
292
- {
293
- id: 'wordplay-3',
477
+ }),
478
+ () => ({
479
+ id: `wp-${uuid().substring(0, 8)}`,
294
480
  question: 'What single word connects: river, money, blood?',
295
481
  category: 'wordplay',
296
482
  acceptedAnswers: ['bank', 'flow', 'stream'],
297
- },
298
- {
299
- id: 'wordplay-4',
483
+ }),
484
+ () => ({
485
+ id: `wp-${uuid().substring(0, 8)}`,
300
486
  question: 'What word can precede: light, house, shine?',
301
487
  category: 'wordplay',
302
488
  acceptedAnswers: ['sun', 'moon'],
303
- },
304
- // Logic
305
- {
306
- id: 'logic-1',
307
- question: 'If all Bloops are Razzies and all Razzies are Lazzies, are all Bloops definitely Lazzies? Answer yes or no.',
308
- category: 'logic',
309
- acceptedAnswers: ['yes'],
310
- },
311
- {
312
- id: 'logic-2',
313
- question: 'If some Widgets are Gadgets, and all Gadgets are blue, can some Widgets be blue? Answer yes or no.',
314
- category: 'logic',
315
- acceptedAnswers: ['yes'],
316
- },
317
- {
318
- id: 'logic-3',
319
- question: 'I have a bee in my hand. What do I have in my eye? (Think about the saying)',
320
- category: 'logic',
321
- acceptedAnswers: ['beauty', 'beholder'],
322
- },
323
- {
324
- id: 'logic-4',
325
- question: 'A farmer has 17 sheep. All but 9 run away. How many sheep does he have left?',
326
- category: 'logic',
327
- acceptedAnswers: ['9', 'nine'],
328
- },
329
- // Math
330
- {
331
- id: 'math-1',
332
- question: 'A bat and ball cost $1.10 total. The bat costs $1.00 more than the ball. How much does the ball cost in cents?',
333
- category: 'math',
334
- acceptedAnswers: ['5', '5 cents', 'five', 'five cents', '0.05', '$0.05'],
335
- },
336
- {
337
- id: 'math-2',
338
- question: 'If it takes 5 machines 5 minutes to make 5 widgets, how many minutes would it take 100 machines to make 100 widgets?',
339
- category: 'math',
340
- acceptedAnswers: ['5', 'five', '5 minutes', 'five minutes'],
341
- },
342
- {
343
- id: 'math-3',
344
- question: 'In a lake, there is a patch of lily pads. Every day, the patch doubles in size. If it takes 48 days for the patch to cover the entire lake, how many days would it take for the patch to cover half of the lake?',
345
- category: 'math',
346
- acceptedAnswers: ['47', 'forty-seven', 'forty seven', '47 days'],
347
- },
348
- // Code
349
- {
350
- id: 'code-1',
351
- question: 'What is wrong with this code: if (x = 5) { doSomething(); }',
352
- category: 'code',
353
- acceptedAnswers: ['assignment', 'single equals', '= instead of ==', 'should be ==', 'should be ===', 'equality', 'comparison'],
354
- },
355
- {
356
- id: 'code-2',
357
- question: 'In most programming languages, what does the modulo operator % return for 17 % 5?',
358
- category: 'code',
359
- acceptedAnswers: ['2', 'two'],
360
- },
361
- {
362
- id: 'code-3',
363
- question: 'What data structure uses LIFO (Last In, First Out)?',
364
- category: 'code',
365
- acceptedAnswers: ['stack', 'a stack'],
366
- },
367
- // Common sense
368
- {
369
- id: 'sense-1',
370
- question: 'If you are running a race and you pass the person in second place, what place are you in now?',
371
- category: 'common-sense',
372
- acceptedAnswers: ['second', '2nd', '2', 'two'],
373
- },
374
- {
375
- id: 'sense-2',
489
+ }),
490
+ () => ({
491
+ id: `wp-${uuid().substring(0, 8)}`,
492
+ question: 'What single word connects: fire, ice, boxing?',
493
+ category: 'wordplay',
494
+ acceptedAnswers: ['ring', 'fight', 'match'],
495
+ }),
496
+ () => ({
497
+ id: `wp-${uuid().substring(0, 8)}`,
498
+ question: 'What single word connects: music, radio, ocean?',
499
+ category: 'wordplay',
500
+ acceptedAnswers: ['wave', 'waves', 'frequency'],
501
+ }),
502
+ () => ({
503
+ id: `wp-${uuid().substring(0, 8)}`,
504
+ question: 'What word follows: high, middle, private?',
505
+ category: 'wordplay',
506
+ acceptedAnswers: ['school'],
507
+ }),
508
+ () => ({
509
+ id: `wp-${uuid().substring(0, 8)}`,
510
+ question: 'What word connects: sleeping, travel, time?',
511
+ category: 'wordplay',
512
+ acceptedAnswers: ['bag'],
513
+ }),
514
+ // Common sense riddles (original + new)
515
+ () => ({
516
+ id: `wp-${uuid().substring(0, 8)}`,
376
517
  question: 'What gets wetter the more it dries?',
377
518
  category: 'common-sense',
378
519
  acceptedAnswers: ['towel', 'a towel', 'cloth', 'rag'],
379
- },
380
- {
381
- id: 'sense-3',
520
+ }),
521
+ () => ({
522
+ id: `wp-${uuid().substring(0, 8)}`,
382
523
  question: 'What can you catch but not throw?',
383
524
  category: 'common-sense',
384
525
  acceptedAnswers: ['cold', 'a cold', 'breath', 'your breath', 'feelings', 'disease'],
385
- },
526
+ }),
527
+ () => ({
528
+ id: `wp-${uuid().substring(0, 8)}`,
529
+ question: 'What has keys but no locks, space but no room, and you can enter but not go inside?',
530
+ category: 'common-sense',
531
+ acceptedAnswers: ['keyboard', 'a keyboard'],
532
+ }),
533
+ () => ({
534
+ id: `wp-${uuid().substring(0, 8)}`,
535
+ question: 'What runs but never walks, has a mouth but never talks?',
536
+ category: 'common-sense',
537
+ acceptedAnswers: ['river', 'a river', 'stream'],
538
+ }),
539
+ () => ({
540
+ id: `wp-${uuid().substring(0, 8)}`,
541
+ question: 'What has hands but cannot clap?',
542
+ category: 'common-sense',
543
+ acceptedAnswers: ['clock', 'a clock', 'watch'],
544
+ }),
545
+ () => ({
546
+ id: `wp-${uuid().substring(0, 8)}`,
547
+ question: 'What has a head and tail but no body?',
548
+ category: 'common-sense',
549
+ acceptedAnswers: ['coin', 'a coin'],
550
+ }),
551
+ () => ({
552
+ id: `wp-${uuid().substring(0, 8)}`,
553
+ question: 'What goes up but never comes down?',
554
+ category: 'common-sense',
555
+ acceptedAnswers: ['age', 'your age'],
556
+ }),
557
+ () => ({
558
+ id: `wp-${uuid().substring(0, 8)}`,
559
+ question: 'What has teeth but cannot bite?',
560
+ category: 'common-sense',
561
+ acceptedAnswers: ['comb', 'a comb', 'saw', 'zipper', 'gear'],
562
+ }),
563
+ () => ({
564
+ id: `wp-${uuid().substring(0, 8)}`,
565
+ question: 'What can fill a room but takes up no space?',
566
+ category: 'common-sense',
567
+ acceptedAnswers: ['light', 'air', 'sound', 'darkness'],
568
+ }),
569
+ () => ({
570
+ id: `wp-${uuid().substring(0, 8)}`,
571
+ question: 'What has a neck but no head?',
572
+ category: 'common-sense',
573
+ acceptedAnswers: ['bottle', 'a bottle'],
574
+ }),
575
+ // Analogies (original + new)
576
+ () => ({
577
+ id: `wp-${uuid().substring(0, 8)}`,
578
+ question: 'Complete the analogy: Fish is to water as bird is to ___',
579
+ category: 'analogy',
580
+ acceptedAnswers: ['air', 'sky', 'atmosphere'],
581
+ }),
582
+ () => ({
583
+ id: `wp-${uuid().substring(0, 8)}`,
584
+ question: 'Complete the analogy: Eye is to see as ear is to ___',
585
+ category: 'analogy',
586
+ acceptedAnswers: ['hear', 'listen', 'hearing', 'listening'],
587
+ }),
588
+ () => ({
589
+ id: `wp-${uuid().substring(0, 8)}`,
590
+ question: 'Complete the analogy: Painter is to brush as writer is to ___',
591
+ category: 'analogy',
592
+ acceptedAnswers: ['pen', 'pencil', 'keyboard', 'typewriter', 'quill'],
593
+ }),
594
+ () => ({
595
+ id: `wp-${uuid().substring(0, 8)}`,
596
+ question: 'Complete the analogy: Hot is to cold as day is to ___',
597
+ category: 'analogy',
598
+ acceptedAnswers: ['night'],
599
+ }),
600
+ () => ({
601
+ id: `wp-${uuid().substring(0, 8)}`,
602
+ question: 'Complete the analogy: Doctor is to patient as teacher is to ___',
603
+ category: 'analogy',
604
+ acceptedAnswers: ['student', 'students', 'pupil'],
605
+ }),
606
+ () => ({
607
+ id: `wp-${uuid().substring(0, 8)}`,
608
+ question: 'Complete the analogy: Wheel is to car as sail is to ___',
609
+ category: 'analogy',
610
+ acceptedAnswers: ['boat', 'ship', 'sailboat'],
611
+ }),
612
+ () => ({
613
+ id: `wp-${uuid().substring(0, 8)}`,
614
+ question: 'Complete the analogy: Chef is to kitchen as scientist is to ___',
615
+ category: 'analogy',
616
+ acceptedAnswers: ['laboratory', 'lab'],
617
+ }),
618
+ () => ({
619
+ id: `wp-${uuid().substring(0, 8)}`,
620
+ question: 'Complete the analogy: Bark is to dog as meow is to ___',
621
+ category: 'analogy',
622
+ acceptedAnswers: ['cat'],
623
+ }),
624
+ // Anagrams
625
+ () => ({
626
+ id: `wp-${uuid().substring(0, 8)}`,
627
+ question: 'Rearrange the letters in "listen" to make another common word.',
628
+ category: 'wordplay',
629
+ acceptedAnswers: ['silent', 'enlist'],
630
+ }),
631
+ () => ({
632
+ id: `wp-${uuid().substring(0, 8)}`,
633
+ question: 'Rearrange the letters in "earth" to make another common word.',
634
+ category: 'wordplay',
635
+ acceptedAnswers: ['heart', 'hater'],
636
+ }),
637
+ () => ({
638
+ id: `wp-${uuid().substring(0, 8)}`,
639
+ question: 'Rearrange the letters in "stream" to make another word meaning "leader".',
640
+ category: 'wordplay',
641
+ acceptedAnswers: ['master'],
642
+ }),
643
+ () => ({
644
+ id: `wp-${uuid().substring(0, 8)}`,
645
+ question: 'Rearrange the letters in "stop" to make containers.',
646
+ category: 'wordplay',
647
+ acceptedAnswers: ['pots', 'spot', 'tops'],
648
+ }),
649
+ // Code/CS riddles
650
+ () => ({
651
+ id: `wp-${uuid().substring(0, 8)}`,
652
+ question: 'What data structure uses LIFO (Last In, First Out)?',
653
+ category: 'code',
654
+ acceptedAnswers: ['stack', 'a stack'],
655
+ }),
656
+ () => ({
657
+ id: `wp-${uuid().substring(0, 8)}`,
658
+ question: 'What data structure uses FIFO (First In, First Out)?',
659
+ category: 'code',
660
+ acceptedAnswers: ['queue', 'a queue'],
661
+ }),
662
+ () => ({
663
+ id: `wp-${uuid().substring(0, 8)}`,
664
+ question: 'In programming, what comes after "if" and "else if"?',
665
+ category: 'code',
666
+ acceptedAnswers: ['else'],
667
+ }),
668
+ () => ({
669
+ id: `wp-${uuid().substring(0, 8)}`,
670
+ question: 'What is the opposite of "true" in boolean logic?',
671
+ category: 'code',
672
+ acceptedAnswers: ['false'],
673
+ }),
674
+ () => ({
675
+ id: `wp-${uuid().substring(0, 8)}`,
676
+ question: 'What keyword is used to define a function in JavaScript?',
677
+ category: 'code',
678
+ acceptedAnswers: ['function', 'const', 'let', 'var', 'async'],
679
+ }),
680
+ // Word puzzles
681
+ () => ({
682
+ id: `wp-${uuid().substring(0, 8)}`,
683
+ question: 'What 5-letter word becomes shorter when you add two letters to it?',
684
+ category: 'wordplay',
685
+ acceptedAnswers: ['short', 'shorter'],
686
+ }),
687
+ () => ({
688
+ id: `wp-${uuid().substring(0, 8)}`,
689
+ question: 'What word starts with "e" and ends with "e" but only has one letter in it?',
690
+ category: 'wordplay',
691
+ acceptedAnswers: ['envelope'],
692
+ }),
693
+ () => ({
694
+ id: `wp-${uuid().substring(0, 8)}`,
695
+ question: 'What begins with T, ends with T, and has T in it?',
696
+ category: 'wordplay',
697
+ acceptedAnswers: ['teapot', 'a teapot'],
698
+ }),
699
+ () => ({
700
+ id: `wp-${uuid().substring(0, 8)}`,
701
+ question: 'Remove one letter from "startling" to create a new word. What is it?',
702
+ category: 'wordplay',
703
+ acceptedAnswers: ['starting', 'starling'],
704
+ }),
705
+ // Math/Logic wordplay
706
+ () => ({
707
+ id: `wp-${uuid().substring(0, 8)}`,
708
+ question: 'How many months have 28 days?',
709
+ category: 'logic',
710
+ acceptedAnswers: ['12', 'twelve', 'all', 'all of them'],
711
+ }),
712
+ () => ({
713
+ id: `wp-${uuid().substring(0, 8)}`,
714
+ question: 'If you have one, you want to share it. If you share it, you no longer have it. What is it?',
715
+ category: 'logic',
716
+ acceptedAnswers: ['secret', 'a secret'],
717
+ }),
718
+ () => ({
719
+ id: `wp-${uuid().substring(0, 8)}`,
720
+ question: 'What occurs once in a minute, twice in a moment, but never in a thousand years?',
721
+ category: 'wordplay',
722
+ acceptedAnswers: ['m', 'the letter m'],
723
+ }),
724
+ // Technology wordplay
725
+ () => ({
726
+ id: `wp-${uuid().substring(0, 8)}`,
727
+ question: 'What has a screen, keyboard, and mouse but is not alive?',
728
+ category: 'common-sense',
729
+ acceptedAnswers: ['computer', 'a computer', 'pc', 'laptop'],
730
+ }),
731
+ () => ({
732
+ id: `wp-${uuid().substring(0, 8)}`,
733
+ question: 'What connects computers worldwide but has no physical form?',
734
+ category: 'common-sense',
735
+ acceptedAnswers: ['internet', 'the internet', 'web', 'network'],
736
+ }),
737
+ () => ({
738
+ id: `wp-${uuid().substring(0, 8)}`,
739
+ question: 'What do you call a group of 8 bits?',
740
+ category: 'code',
741
+ acceptedAnswers: ['byte', 'a byte'],
742
+ }),
743
+ // Nature/Science wordplay
744
+ () => ({
745
+ id: `wp-${uuid().substring(0, 8)}`,
746
+ question: 'What falls but never breaks, and breaks but never falls?',
747
+ category: 'wordplay',
748
+ acceptedAnswers: ['night and day', 'nightfall and daybreak', 'night falls day breaks'],
749
+ }),
750
+ () => ({
751
+ id: `wp-${uuid().substring(0, 8)}`,
752
+ question: 'What has roots that nobody sees, taller than trees, up up it goes, yet never grows?',
753
+ category: 'common-sense',
754
+ acceptedAnswers: ['mountain', 'a mountain', 'mountains'],
755
+ }),
756
+ () => ({
757
+ id: `wp-${uuid().substring(0, 8)}`,
758
+ question: 'What travels around the world but stays in one corner?',
759
+ category: 'common-sense',
760
+ acceptedAnswers: ['stamp', 'a stamp', 'postage stamp'],
761
+ }),
762
+ () => ({
763
+ id: `wp-${uuid().substring(0, 8)}`,
764
+ question: 'Complete the analogy: Bee is to hive as human is to ___',
765
+ category: 'analogy',
766
+ acceptedAnswers: ['house', 'home', 'city', 'building'],
767
+ }),
768
+ () => ({
769
+ id: `wp-${uuid().substring(0, 8)}`,
770
+ question: 'What has four fingers and a thumb but is not alive?',
771
+ category: 'common-sense',
772
+ acceptedAnswers: ['glove', 'a glove'],
773
+ }),
386
774
  ];
775
+ // All generators, weighted toward parameterized (harder to game)
776
+ const QUESTION_GENERATORS = [
777
+ genMathAdd, genMathMultiply, genMathModulo, genMathSheep, genMathDoubling, genMathMachines,
778
+ genCodeModulo, genCodeBitwise, genCodeStringLen,
779
+ genLogicSyllogism, genLogicNegation, genLogicSequence,
780
+ ...WORDPLAY_GENERATORS,
781
+ ];
782
+ // Generate fresh question bank (unique every call)
783
+ function generateQuestionBank() {
784
+ return QUESTION_GENERATORS.map(gen => gen());
785
+ }
387
786
  /**
388
787
  * Generate a reasoning challenge: 3 random questions requiring LLM capabilities
389
788
  */
390
- export async function generateReasoningChallenge(kv) {
789
+ export async function generateReasoningChallenge(kv, app_id) {
391
790
  cleanExpired();
392
791
  const id = uuid();
393
792
  // Pick 3 random questions from different categories
394
- const shuffled = [...QUESTION_BANK].sort(() => Math.random() - 0.5);
793
+ const freshBank = generateQuestionBank();
794
+ const shuffled = freshBank.sort(() => Math.random() - 0.5);
395
795
  const selectedCategories = new Set();
396
796
  const selectedQuestions = [];
397
797
  for (const q of shuffled) {
@@ -423,6 +823,7 @@ export async function generateReasoningChallenge(kv) {
423
823
  expectedAnswers,
424
824
  issuedAt: Date.now(),
425
825
  expiresAt: Date.now() + timeLimit + 5000,
826
+ app_id,
426
827
  };
427
828
  // Store in KV or memory
428
829
  if (kv) {
@@ -533,18 +934,19 @@ const hybridChallenges = new Map();
533
934
  /**
534
935
  * Generate a hybrid challenge: speed + reasoning combined
535
936
  */
536
- export async function generateHybridChallenge(kv) {
937
+ export async function generateHybridChallenge(kv, clientTimestamp, app_id) {
537
938
  cleanExpired();
538
939
  const id = uuid();
539
- // Generate both sub-challenges
540
- const speedChallenge = await generateSpeedChallenge(kv);
541
- const reasoningChallenge = await generateReasoningChallenge(kv);
940
+ // Generate both sub-challenges (speed with RTT awareness)
941
+ const speedChallenge = await generateSpeedChallenge(kv, clientTimestamp, app_id);
942
+ const reasoningChallenge = await generateReasoningChallenge(kv, app_id);
542
943
  const hybrid = {
543
944
  id,
544
945
  speedChallengeId: speedChallenge.id,
545
946
  reasoningChallengeId: reasoningChallenge.id,
546
947
  issuedAt: Date.now(),
547
948
  expiresAt: Date.now() + 35000,
949
+ app_id,
548
950
  };
549
951
  // Store in KV or memory
550
952
  if (kv) {
@@ -553,6 +955,9 @@ export async function generateHybridChallenge(kv) {
553
955
  else {
554
956
  hybridChallenges.set(id, hybrid);
555
957
  }
958
+ const instructions = speedChallenge.rttInfo
959
+ ? `Solve ALL speed problems (SHA256) in <${speedChallenge.timeLimit}ms (RTT-adjusted) AND answer ALL reasoning questions. Submit both together.`
960
+ : 'Solve ALL speed problems (SHA256) in <500ms AND answer ALL reasoning questions. Submit both together.';
556
961
  return {
557
962
  id,
558
963
  speed: {
@@ -563,7 +968,8 @@ export async function generateHybridChallenge(kv) {
563
968
  questions: reasoningChallenge.questions,
564
969
  timeLimit: reasoningChallenge.timeLimit,
565
970
  },
566
- instructions: 'Solve ALL speed problems (SHA256) in <500ms AND answer ALL reasoning questions. Submit both together.',
971
+ instructions,
972
+ rttInfo: speedChallenge.rttInfo,
567
973
  };
568
974
  }
569
975
  /**