@funnycaptcha/spot-diff 0.2.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -49,11 +49,12 @@ function proofInput(c) {
49
49
 
50
50
  // src/render.ts
51
51
  var STR = {
52
- zh: { title: "\u627E\u51FA\u4E24\u5904\u4E0D\u540C", hint: "\u70B9\u51FB\u53F3\u4FA7\u7F51\u683C\u4E2D\u4E0D\u540C\u7684\u683C\u5B50", found: "\u5DF2\u627E\u5230", success: "\u9A8C\u8BC1\u6210\u529F", fail: "\u627E\u5F97\u4E0D\u5BF9\uFF0C\u518D\u6765\u4E00\u6B21" },
53
- en: { title: "Spot 2 differences", hint: "Click the differing cells on the right grid", found: "Found", success: "Verified", fail: "Not quite, try again" }
52
+ zh: { title: "\u627E\u51FA\u4E24\u5904\u4E0D\u540C", hint: "\u70B9\u51FB\u53F3\u4FA7\u7F51\u683C\u4E2D\u4E0D\u540C\u7684\u683C\u5B50", found: "\u5DF2\u627E\u5230", success: "\u9A8C\u8BC1\u6210\u529F", fail: "\u627E\u5F97\u4E0D\u5BF9\uFF0C\u518D\u6765\u4E00\u6B21", refresh: "\u5237\u65B0" },
53
+ en: { title: "Spot 2 differences", hint: "Click the differing cells on the right grid", found: "Found", success: "Verified", fail: "Not quite, try again", refresh: "Refresh" }
54
54
  };
55
55
  function createSpotDiffInstance(container, config) {
56
56
  const t = STR[config.locale];
57
+ const theme = config.theme ?? "light";
57
58
  let current;
58
59
  let listeners = [];
59
60
  let startTime = Date.now();
@@ -70,20 +71,26 @@ function createSpotDiffInstance(container, config) {
70
71
  startTime = Date.now();
71
72
  container.innerHTML = `
72
73
  <style>
73
- .fc-spot-diff{font-family:-apple-system,system-ui,sans-serif;width:360px;padding:16px;box-sizing:border-box}
74
- .fc-spot-diff-title{font-size:14px;color:#333;margin-bottom:6px;text-align:center;font-weight:600}
75
- .fc-spot-diff-hint{font-size:12px;color:#888;margin-bottom:12px;text-align:center}
74
+ .fc-spot-diff{font-family:-apple-system,system-ui,sans-serif;max-width:360px;width:100%;padding:16px;box-sizing:border-box}
75
+ .fc-spot-diff[data-theme="light"]{--fc-bg:#ffffff;--fc-surface:#f6f7f9;--fc-text:#0f172a;--fc-text-soft:#64748b;--fc-border:#e2e8f0;--fc-accent:#6366f1;--fc-accent-soft:#eef2ff;--fc-success:#16a34a;--fc-danger:#dc2626}
76
+ .fc-spot-diff[data-theme="dark"]{--fc-bg:#1e2544;--fc-surface:#171c36;--fc-text:#e5e9f0;--fc-text-soft:#94a3b8;--fc-border:#2a3358;--fc-accent:#818cf8;--fc-accent-soft:#252b5c;--fc-success:#4ade80;--fc-danger:#f87171}
77
+ .fc-spot-diff{background:var(--fc-bg);border:1px solid var(--fc-border);border-radius:10px;color:var(--fc-text)}
78
+ .fc-spot-diff-title{font-size:14px;color:var(--fc-text);margin-bottom:6px;text-align:center;font-weight:600}
79
+ .fc-spot-diff-hint{font-size:12px;color:var(--fc-text-soft);margin-bottom:12px;text-align:center}
76
80
  .fc-spot-diff-grids{display:flex;gap:16px;justify-content:center}
77
- .fc-spot-diff-grid{display:grid;grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,48px);gap:4px;background:#f5f5f5;padding:8px;border-radius:8px;border:1px solid #e0e0e0}
78
- .fc-spot-diff-cell{display:flex;align-items:center;justify-content:center;font-size:26px;background:#fff;border-radius:6px;user-select:none}
81
+ .fc-spot-diff-grid{display:grid;grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,48px);gap:4px;background:var(--fc-surface);padding:8px;border-radius:8px;border:1px solid var(--fc-border)}
82
+ .fc-spot-diff-cell{display:flex;align-items:center;justify-content:center;font-size:26px;background:var(--fc-bg);border-radius:6px;user-select:none}
79
83
  .fc-spot-diff-clickable{cursor:pointer;transition:transform .12s,box-shadow .12s}
80
84
  .fc-spot-diff-clickable:hover{transform:scale(1.06)}
81
- .fc-spot-diff-marked{box-shadow:0 0 0 3px #4a90d9 inset;background:#eaf3ff}
82
- .fc-spot-diff-wrong{box-shadow:0 0 0 3px #e53935 inset;background:#fdecea}
83
- .fc-spot-diff-status{font-size:13px;min-height:18px;text-align:center;margin-top:12px;color:#2e7d32}
84
- .fc-spot-diff-status-fail{color:#e53935}
85
+ .fc-spot-diff-marked{box-shadow:0 0 0 3px var(--fc-accent) inset;background:var(--fc-accent-soft)}
86
+ .fc-spot-diff-wrong{box-shadow:0 0 0 3px var(--fc-danger) inset;background:var(--fc-danger)}
87
+ .fc-spot-diff-status{font-size:13px;min-height:18px;text-align:center;margin-top:12px;color:var(--fc-success)}
88
+ .fc-spot-diff-status-fail{color:var(--fc-danger)}
89
+ .fc-spot-diff-row{display:flex;justify-content:center;margin-top:8px}
90
+ .fc-spot-diff-refresh{padding:4px 12px;font-size:12px;border:1px solid var(--fc-border);background:var(--fc-surface);color:var(--fc-text-soft);border-radius:6px;cursor:pointer}
91
+ .fc-spot-diff-refresh:hover{border-color:var(--fc-accent);color:var(--fc-accent)}
85
92
  </style>
86
- <div class="fc-spot-diff">
93
+ <div class="fc-spot-diff" data-theme="${theme}">
87
94
  <div class="fc-spot-diff-title">${t.title}</div>
88
95
  <div class="fc-spot-diff-hint">${t.hint}</div>
89
96
  <div class="fc-spot-diff-grids">
@@ -91,10 +98,15 @@ function createSpotDiffInstance(container, config) {
91
98
  <div class="fc-spot-diff-grid fc-spot-diff-grid-b">${renderGrid(current.gridB, true)}</div>
92
99
  </div>
93
100
  <div class="fc-spot-diff-status">${t.found} 0/${current.diffs.length}</div>
101
+ <div class="fc-spot-diff-row">
102
+ <button class="fc-spot-diff-refresh">${t.refresh}</button>
103
+ </div>
94
104
  </div>
95
105
  `;
96
106
  const gridB = container.querySelector(".fc-spot-diff-grid-b");
97
107
  const status = container.querySelector(".fc-spot-diff-status");
108
+ const refreshBtn = container.querySelector(".fc-spot-diff-refresh");
109
+ refreshBtn.addEventListener("click", render);
98
110
  gridB.querySelectorAll(".fc-spot-diff-clickable").forEach((el) => {
99
111
  el.addEventListener("click", async () => {
100
112
  if (finished) return;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/render.ts","../src/challenge.ts","../src/verifier.ts"],"sourcesContent":["import { defineCaptcha, type CaptchaConfig, type CaptchaPlugin } from '@funnycaptcha/core';\nimport { createSpotDiffInstance } from './render.js';\n\nexport * from './challenge.js';\nexport * from './verifier.js';\n\nexport const spotDiffPlugin: CaptchaPlugin = {\n id: 'spot-diff',\n category: 'creative',\n create: createSpotDiffInstance,\n describe: (locale) => ({\n name: locale === 'zh' ? '找不同' : 'Spot the Difference',\n description: locale === 'zh'\n ? '对比两个 emoji 网格,找出所有不同之处'\n : 'Compare two emoji grids and spot all differences.',\n tags: ['spot-diff', 'emoji', 'creative'],\n }),\n};\n\ndefineCaptcha(spotDiffPlugin);\n\nexport function mountSpotDiff(container: HTMLElement, config: CaptchaConfig) {\n return createSpotDiffInstance(container, config);\n}\n","import type { CaptchaConfig, CaptchaInstance, CaptchaResult } from '@funnycaptcha/core';\nimport { hashProof } from '@funnycaptcha/core';\nimport { generateChallenge, verifyDiffs, type SpotDiffChallenge } from './challenge.js';\n\nconst STR = {\n zh: { title: '找出两处不同', hint: '点击右侧网格中不同的格子', found: '已找到', success: '验证成功', fail: '找得不对,再来一次' },\n en: { title: 'Spot 2 differences', hint: 'Click the differing cells on the right grid', found: 'Found', success: 'Verified', fail: 'Not quite, try again' },\n};\n\nexport function createSpotDiffInstance(\n container: HTMLElement,\n config: CaptchaConfig,\n): CaptchaInstance {\n const t = STR[config.locale];\n let current: SpotDiffChallenge;\n let listeners: ((r: CaptchaResult) => void)[] = [];\n let startTime = Date.now();\n let marked: Set<number> = new Set();\n let finished = false;\n\n function renderGrid(emojis: string[], clickable: boolean): string {\n const cls = clickable ? 'fc-spot-diff-cell fc-spot-diff-clickable' : 'fc-spot-diff-cell';\n return emojis.map((e, i) => `<div class=\"${cls}\" data-idx=\"${i}\">${e}</div>`).join('');\n }\n\n function render() {\n current = generateChallenge();\n marked = new Set();\n finished = false;\n startTime = Date.now();\n container.innerHTML = `\n <style>\n .fc-spot-diff{font-family:-apple-system,system-ui,sans-serif;width:360px;padding:16px;box-sizing:border-box}\n .fc-spot-diff-title{font-size:14px;color:#333;margin-bottom:6px;text-align:center;font-weight:600}\n .fc-spot-diff-hint{font-size:12px;color:#888;margin-bottom:12px;text-align:center}\n .fc-spot-diff-grids{display:flex;gap:16px;justify-content:center}\n .fc-spot-diff-grid{display:grid;grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,48px);gap:4px;background:#f5f5f5;padding:8px;border-radius:8px;border:1px solid #e0e0e0}\n .fc-spot-diff-cell{display:flex;align-items:center;justify-content:center;font-size:26px;background:#fff;border-radius:6px;user-select:none}\n .fc-spot-diff-clickable{cursor:pointer;transition:transform .12s,box-shadow .12s}\n .fc-spot-diff-clickable:hover{transform:scale(1.06)}\n .fc-spot-diff-marked{box-shadow:0 0 0 3px #4a90d9 inset;background:#eaf3ff}\n .fc-spot-diff-wrong{box-shadow:0 0 0 3px #e53935 inset;background:#fdecea}\n .fc-spot-diff-status{font-size:13px;min-height:18px;text-align:center;margin-top:12px;color:#2e7d32}\n .fc-spot-diff-status-fail{color:#e53935}\n </style>\n <div class=\"fc-spot-diff\">\n <div class=\"fc-spot-diff-title\">${t.title}</div>\n <div class=\"fc-spot-diff-hint\">${t.hint}</div>\n <div class=\"fc-spot-diff-grids\">\n <div class=\"fc-spot-diff-grid\">${renderGrid(current.gridA, false)}</div>\n <div class=\"fc-spot-diff-grid fc-spot-diff-grid-b\">${renderGrid(current.gridB, true)}</div>\n </div>\n <div class=\"fc-spot-diff-status\">${t.found} 0/${current.diffs.length}</div>\n </div>\n `;\n const gridB = container.querySelector('.fc-spot-diff-grid-b') as HTMLDivElement;\n const status = container.querySelector('.fc-spot-diff-status') as HTMLDivElement;\n\n gridB.querySelectorAll('.fc-spot-diff-clickable').forEach(el => {\n el.addEventListener('click', async () => {\n if (finished) return;\n const cell = el as HTMLElement;\n const idx = Number(cell.dataset.idx);\n if (marked.has(idx)) {\n // 取消标记\n marked.delete(idx);\n cell.classList.remove('fc-spot-diff-marked');\n } else {\n marked.add(idx);\n cell.classList.add('fc-spot-diff-marked');\n }\n status.textContent = `${t.found} ${marked.size}/${current.diffs.length}`;\n // 标记数达到差异数时自动校验\n if (marked.size === current.diffs.length) {\n const userDiffs = Array.from(marked);\n const ok = verifyDiffs(current, userDiffs);\n if (ok) {\n finished = true;\n const result: CaptchaResult = {\n success: true,\n proof: await hashProof(userDiffs.slice().sort((a, b) => a - b).join(',')),\n duration: Date.now() - startTime,\n };\n status.textContent = t.success;\n config.onVerify?.(result);\n listeners.forEach(cb => cb(result));\n } else {\n // 标错:高亮错误后重置\n finished = true;\n status.textContent = t.fail;\n status.classList.add('fc-spot-diff-status-fail');\n userDiffs.forEach(i => {\n const node = gridB.querySelector(`[data-idx=\"${i}\"]`) as HTMLElement;\n node.classList.add('fc-spot-diff-wrong');\n });\n setTimeout(render, 800);\n }\n }\n });\n });\n }\n\n return {\n mount: () => render(),\n reset: () => render(),\n destroy: () => { container.innerHTML = ''; listeners = []; },\n onResult: cb => listeners.push(cb),\n };\n}\n","// 找不同题目:两个 3x3 emoji 网格,其中 2 处不同\nexport interface SpotDiffChallenge {\n // 参考网格(9 个 emoji)\n gridA: string[];\n // 待找不同的网格(9 个 emoji)\n gridB: string[];\n // 差异位置索引(0-8)\n diffs: number[];\n}\n\n// emoji 池\nconst POOL: string[] = ['🍎', '🍊', '🍋', '🍇', '🍓', '🍑', '🍒', '🍉', '🥝'];\nconst GRID_SIZE = 9;\nconst DIFF_COUNT = 2;\n\nfunction randInt(min: number, max: number): number {\n return Math.floor(Math.random() * (max - min + 1)) + min;\n}\n\n// Fisher-Yates 洗牌\nfunction shuffle<T>(arr: readonly T[]): T[] {\n const a = arr.slice();\n for (let i = a.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = a[i]!;\n a[i] = a[j]!;\n a[j] = tmp;\n }\n return a;\n}\n\n// 从池中挑选一个与 original 不同的 emoji\nfunction pickDifferent(original: string): string {\n let next = POOL[randInt(0, POOL.length - 1)]!;\n while (next === original) {\n next = POOL[randInt(0, POOL.length - 1)]!;\n }\n return next;\n}\n\n// 生成题目:gridA 为 9 个不重复 emoji 的随机排列,gridB 在 2 个位置替换为不同 emoji\nexport function generateChallenge(): SpotDiffChallenge {\n const gridA = shuffle(POOL);\n // 随机选 2 个不重复的位置作为差异\n const positions = shuffle(Array.from({ length: GRID_SIZE }, (_, i) => i));\n const diffs = positions.slice(0, DIFF_COUNT).sort((a, b) => a - b);\n const gridB = gridA.slice();\n for (const pos of diffs) {\n gridB[pos] = pickDifferent(gridA[pos]!);\n }\n return { gridA, gridB, diffs };\n}\n\n// 校验用户找出的差异位置是否正确\nexport function verifyDiffs(c: SpotDiffChallenge, userDiffs: number[]): boolean {\n if (userDiffs.length !== c.diffs.length) return false;\n const sorted = userDiffs.slice().sort((a, b) => a - b);\n return sorted.every((v, i) => v === c.diffs[i]);\n}\n\n// 构造 proof 输入串:差异位置升序逗号连接\nexport function proofInput(c: SpotDiffChallenge): string {\n return c.diffs.slice().sort((a, b) => a - b).join(',');\n}\n","import { hashProof } from '@funnycaptcha/core';\nimport { generateChallenge, proofInput, type SpotDiffChallenge } from './challenge.js';\n\n// 服务端签发题目\nexport function issueSpotDiffChallenge(): { challenge: SpotDiffChallenge; payload: unknown } {\n const challenge = generateChallenge();\n return { challenge, payload: { gridA: challenge.gridA, gridB: challenge.gridB } };\n}\n\n// 服务端校验 proof\nexport async function verifySpotDiffProof(\n challenge: SpotDiffChallenge,\n proof: string,\n): Promise<boolean> {\n const expected = await hashProof(proofInput(challenge));\n return expected === proof;\n}\n"],"mappings":"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","names":["hashProof","hashProof"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/render.ts","../src/challenge.ts","../src/verifier.ts"],"sourcesContent":["import { defineCaptcha, type CaptchaConfig, type CaptchaPlugin } from '@funnycaptcha/core';\nimport { createSpotDiffInstance } from './render.js';\n\nexport * from './challenge.js';\nexport * from './verifier.js';\n\nexport const spotDiffPlugin: CaptchaPlugin = {\n id: 'spot-diff',\n category: 'creative',\n create: createSpotDiffInstance,\n describe: (locale) => ({\n name: locale === 'zh' ? '找不同' : 'Spot the Difference',\n description: locale === 'zh'\n ? '对比两个 emoji 网格,找出所有不同之处'\n : 'Compare two emoji grids and spot all differences.',\n tags: ['spot-diff', 'emoji', 'creative'],\n }),\n};\n\ndefineCaptcha(spotDiffPlugin);\n\nexport function mountSpotDiff(container: HTMLElement, config: CaptchaConfig) {\n return createSpotDiffInstance(container, config);\n}\n","import type { CaptchaConfig, CaptchaInstance, CaptchaResult } from '@funnycaptcha/core';\nimport { hashProof } from '@funnycaptcha/core';\nimport { generateChallenge, verifyDiffs, type SpotDiffChallenge } from './challenge.js';\n\nconst STR = {\n zh: { title: '找出两处不同', hint: '点击右侧网格中不同的格子', found: '已找到', success: '验证成功', fail: '找得不对,再来一次', refresh: '刷新' },\n en: { title: 'Spot 2 differences', hint: 'Click the differing cells on the right grid', found: 'Found', success: 'Verified', fail: 'Not quite, try again', refresh: 'Refresh' },\n};\n\nexport function createSpotDiffInstance(\n container: HTMLElement,\n config: CaptchaConfig,\n): CaptchaInstance {\n const t = STR[config.locale];\n const theme = config.theme ?? 'light';\n let current: SpotDiffChallenge;\n let listeners: ((r: CaptchaResult) => void)[] = [];\n let startTime = Date.now();\n let marked: Set<number> = new Set();\n let finished = false;\n\n function renderGrid(emojis: string[], clickable: boolean): string {\n const cls = clickable ? 'fc-spot-diff-cell fc-spot-diff-clickable' : 'fc-spot-diff-cell';\n return emojis.map((e, i) => `<div class=\"${cls}\" data-idx=\"${i}\">${e}</div>`).join('');\n }\n\n function render() {\n current = generateChallenge();\n marked = new Set();\n finished = false;\n startTime = Date.now();\n container.innerHTML = `\n <style>\n .fc-spot-diff{font-family:-apple-system,system-ui,sans-serif;max-width:360px;width:100%;padding:16px;box-sizing:border-box}\n .fc-spot-diff[data-theme=\"light\"]{--fc-bg:#ffffff;--fc-surface:#f6f7f9;--fc-text:#0f172a;--fc-text-soft:#64748b;--fc-border:#e2e8f0;--fc-accent:#6366f1;--fc-accent-soft:#eef2ff;--fc-success:#16a34a;--fc-danger:#dc2626}\n .fc-spot-diff[data-theme=\"dark\"]{--fc-bg:#1e2544;--fc-surface:#171c36;--fc-text:#e5e9f0;--fc-text-soft:#94a3b8;--fc-border:#2a3358;--fc-accent:#818cf8;--fc-accent-soft:#252b5c;--fc-success:#4ade80;--fc-danger:#f87171}\n .fc-spot-diff{background:var(--fc-bg);border:1px solid var(--fc-border);border-radius:10px;color:var(--fc-text)}\n .fc-spot-diff-title{font-size:14px;color:var(--fc-text);margin-bottom:6px;text-align:center;font-weight:600}\n .fc-spot-diff-hint{font-size:12px;color:var(--fc-text-soft);margin-bottom:12px;text-align:center}\n .fc-spot-diff-grids{display:flex;gap:16px;justify-content:center}\n .fc-spot-diff-grid{display:grid;grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,48px);gap:4px;background:var(--fc-surface);padding:8px;border-radius:8px;border:1px solid var(--fc-border)}\n .fc-spot-diff-cell{display:flex;align-items:center;justify-content:center;font-size:26px;background:var(--fc-bg);border-radius:6px;user-select:none}\n .fc-spot-diff-clickable{cursor:pointer;transition:transform .12s,box-shadow .12s}\n .fc-spot-diff-clickable:hover{transform:scale(1.06)}\n .fc-spot-diff-marked{box-shadow:0 0 0 3px var(--fc-accent) inset;background:var(--fc-accent-soft)}\n .fc-spot-diff-wrong{box-shadow:0 0 0 3px var(--fc-danger) inset;background:var(--fc-danger)}\n .fc-spot-diff-status{font-size:13px;min-height:18px;text-align:center;margin-top:12px;color:var(--fc-success)}\n .fc-spot-diff-status-fail{color:var(--fc-danger)}\n .fc-spot-diff-row{display:flex;justify-content:center;margin-top:8px}\n .fc-spot-diff-refresh{padding:4px 12px;font-size:12px;border:1px solid var(--fc-border);background:var(--fc-surface);color:var(--fc-text-soft);border-radius:6px;cursor:pointer}\n .fc-spot-diff-refresh:hover{border-color:var(--fc-accent);color:var(--fc-accent)}\n </style>\n <div class=\"fc-spot-diff\" data-theme=\"${theme}\">\n <div class=\"fc-spot-diff-title\">${t.title}</div>\n <div class=\"fc-spot-diff-hint\">${t.hint}</div>\n <div class=\"fc-spot-diff-grids\">\n <div class=\"fc-spot-diff-grid\">${renderGrid(current.gridA, false)}</div>\n <div class=\"fc-spot-diff-grid fc-spot-diff-grid-b\">${renderGrid(current.gridB, true)}</div>\n </div>\n <div class=\"fc-spot-diff-status\">${t.found} 0/${current.diffs.length}</div>\n <div class=\"fc-spot-diff-row\">\n <button class=\"fc-spot-diff-refresh\">${t.refresh}</button>\n </div>\n </div>\n `;\n const gridB = container.querySelector('.fc-spot-diff-grid-b') as HTMLDivElement;\n const status = container.querySelector('.fc-spot-diff-status') as HTMLDivElement;\n const refreshBtn = container.querySelector('.fc-spot-diff-refresh') as HTMLButtonElement;\n refreshBtn.addEventListener('click', render);\n\n gridB.querySelectorAll('.fc-spot-diff-clickable').forEach(el => {\n el.addEventListener('click', async () => {\n if (finished) return;\n const cell = el as HTMLElement;\n const idx = Number(cell.dataset.idx);\n if (marked.has(idx)) {\n // 取消标记\n marked.delete(idx);\n cell.classList.remove('fc-spot-diff-marked');\n } else {\n marked.add(idx);\n cell.classList.add('fc-spot-diff-marked');\n }\n status.textContent = `${t.found} ${marked.size}/${current.diffs.length}`;\n // 标记数达到差异数时自动校验\n if (marked.size === current.diffs.length) {\n const userDiffs = Array.from(marked);\n const ok = verifyDiffs(current, userDiffs);\n if (ok) {\n finished = true;\n const result: CaptchaResult = {\n success: true,\n proof: await hashProof(userDiffs.slice().sort((a, b) => a - b).join(',')),\n duration: Date.now() - startTime,\n };\n status.textContent = t.success;\n config.onVerify?.(result);\n listeners.forEach(cb => cb(result));\n } else {\n // 标错:高亮错误后重置\n finished = true;\n status.textContent = t.fail;\n status.classList.add('fc-spot-diff-status-fail');\n userDiffs.forEach(i => {\n const node = gridB.querySelector(`[data-idx=\"${i}\"]`) as HTMLElement;\n node.classList.add('fc-spot-diff-wrong');\n });\n setTimeout(render, 800);\n }\n }\n });\n });\n }\n\n return {\n mount: () => render(),\n reset: () => render(),\n destroy: () => { container.innerHTML = ''; listeners = []; },\n onResult: cb => listeners.push(cb),\n };\n}\n","// 找不同题目:两个 3x3 emoji 网格,其中 2 处不同\nexport interface SpotDiffChallenge {\n // 参考网格(9 个 emoji)\n gridA: string[];\n // 待找不同的网格(9 个 emoji)\n gridB: string[];\n // 差异位置索引(0-8)\n diffs: number[];\n}\n\n// emoji 池\nconst POOL: string[] = ['🍎', '🍊', '🍋', '🍇', '🍓', '🍑', '🍒', '🍉', '🥝'];\nconst GRID_SIZE = 9;\nconst DIFF_COUNT = 2;\n\nfunction randInt(min: number, max: number): number {\n return Math.floor(Math.random() * (max - min + 1)) + min;\n}\n\n// Fisher-Yates 洗牌\nfunction shuffle<T>(arr: readonly T[]): T[] {\n const a = arr.slice();\n for (let i = a.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = a[i]!;\n a[i] = a[j]!;\n a[j] = tmp;\n }\n return a;\n}\n\n// 从池中挑选一个与 original 不同的 emoji\nfunction pickDifferent(original: string): string {\n let next = POOL[randInt(0, POOL.length - 1)]!;\n while (next === original) {\n next = POOL[randInt(0, POOL.length - 1)]!;\n }\n return next;\n}\n\n// 生成题目:gridA 为 9 个不重复 emoji 的随机排列,gridB 在 2 个位置替换为不同 emoji\nexport function generateChallenge(): SpotDiffChallenge {\n const gridA = shuffle(POOL);\n // 随机选 2 个不重复的位置作为差异\n const positions = shuffle(Array.from({ length: GRID_SIZE }, (_, i) => i));\n const diffs = positions.slice(0, DIFF_COUNT).sort((a, b) => a - b);\n const gridB = gridA.slice();\n for (const pos of diffs) {\n gridB[pos] = pickDifferent(gridA[pos]!);\n }\n return { gridA, gridB, diffs };\n}\n\n// 校验用户找出的差异位置是否正确\nexport function verifyDiffs(c: SpotDiffChallenge, userDiffs: number[]): boolean {\n if (userDiffs.length !== c.diffs.length) return false;\n const sorted = userDiffs.slice().sort((a, b) => a - b);\n return sorted.every((v, i) => v === c.diffs[i]);\n}\n\n// 构造 proof 输入串:差异位置升序逗号连接\nexport function proofInput(c: SpotDiffChallenge): string {\n return c.diffs.slice().sort((a, b) => a - b).join(',');\n}\n","import { hashProof } from '@funnycaptcha/core';\nimport { generateChallenge, proofInput, type SpotDiffChallenge } from './challenge.js';\n\n// 服务端签发题目\nexport function issueSpotDiffChallenge(): { challenge: SpotDiffChallenge; payload: unknown } {\n const challenge = generateChallenge();\n return { challenge, payload: { gridA: challenge.gridA, gridB: challenge.gridB } };\n}\n\n// 服务端校验 proof\nexport async function verifySpotDiffProof(\n challenge: SpotDiffChallenge,\n proof: string,\n): Promise<boolean> {\n const expected = await hashProof(proofInput(challenge));\n return expected === proof;\n}\n"],"mappings":"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","names":["hashProof","hashProof"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnycaptcha/spot-diff",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Spot-the-difference captcha — FunnyChapter",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@funnycaptcha/core": "0.2.0"
18
+ "@funnycaptcha/core": "0.3.0"
19
19
  },
20
20
  "publishConfig": {
21
21
  "access": "public",