// ==UserScript== // @name GGN 猜数字主持助手 // @namespace https://gazellegames.net/ // @version 1.21.0 // @description 为指定 GGN 主题提供猜数字主持面板、HP 和候选数字管理。 // @match https://gazellegames.net/forums.php* // @match https://www.gazellegames.net/forums.php* // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect privatebin.net // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const GuessNumberHostCore = (() => { const MIN_NUMBER = 1; const MAX_NUMBER = 999999; // 随机开局只取这一段,方便主持人播报;手动输入与所有校验仍按 MIN_NUMBER–MAX_NUMBER。 const RANDOM_MIN_NUMBER = 100000; const RANDOM_MAX_NUMBER = 999999; // 候选表按数字本身直接索引,故容量是 MAX_NUMBER + 1,索引 0 保留但永不参与游戏。 const CANDIDATE_COUNT = MAX_NUMBER + 1; const INITIAL_HP = 250; const QUESTION_COST = 15; const GUESS_COST = 1; // 候选数不超过该值时,回复里直接列出全部候选数字。 const CANDIDATE_LIST_LIMIT = 100; // HP 降到该值及以下时,无论候选多少都列出全部候选数字(收尾阶段)。 const ENDGAME_HP_THRESHOLD = 30; // 每局 privatebin 口令长度。 const PASSWORD_LENGTH = 10; // PrivateBin 自动上传:站点、有效期与内容格式。 const PRIVATEBIN_SITE = 'https://privatebin.net/'; const PRIVATEBIN_EXPIRE = '3day'; const PRIVATEBIN_FORMATTER = 'plaintext'; const SCHEMA_VERSION = 1; const DIGIT_LABELS = ['100k', '10k', '1k', '100s', '10s', '1s']; const DIGIT_DIVISORS = [100000, 10000, 1000, 100, 10, 1]; // Base58 字母表:去掉视觉易混的 0、O、I、l,口令与链接片段共用。 const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; function isSafeInteger(value) { return Number.isSafeInteger(value); } function parseSecret(value) { const text = String(value ?? '').trim(); if (!/^\d{1,6}$/.test(text)) return null; const number = Number(text); return number >= MIN_NUMBER && number <= MAX_NUMBER ? number : null; } function randomSecret(randomSource = globalThis.crypto) { if (!randomSource || typeof randomSource.getRandomValues !== 'function') { throw new Error('无法使用安全随机数,请手动输入答案并点击“新游戏”。'); } const range = RANDOM_MAX_NUMBER - RANDOM_MIN_NUMBER + 1; const limit = Math.floor(0x100000000 / range) * range; const sample = new Uint32Array(1); do { randomSource.getRandomValues(sample); } while (sample[0] >= limit); return sample[0] % range + RANDOM_MIN_NUMBER; } function randomPassword(length = PASSWORD_LENGTH, randomSource = globalThis.crypto) { if (!randomSource || typeof randomSource.getRandomValues !== 'function') { throw new Error('无法生成安全随机口令,请检查浏览器是否支持加密随机数。'); } const alphabet = BASE58_ALPHABET; const range = alphabet.length; const limit = Math.floor(0x100000000 / range) * range; const sample = new Uint32Array(1); const pick = () => { do { randomSource.getRandomValues(sample); } while (sample[0] >= limit); return alphabet[sample[0] % range]; }; for (;;) { let password = ''; for (let index = 0; index < length; index += 1) password += pick(); if (/[A-Z]/.test(password) && /[a-z]/.test(password) && /[0-9]/.test(password)) return password; } } function startPostText(link) { return [ '[align=center][size=7]New Round![/size][/align]', '', `${link}`, ].join('\n'); } function winPostText(secret, password, link) { return [ '[align=center][size=7]Win!', '', `The number was ${secret}`, '', `The password was ${password}[/size][/align]`, '', `${link}`, ].join('\n'); } function parsePayload(value) { if (!value || typeof value !== 'object') return null; if (parseSecret(value.secret) === null) return null; if (typeof value.password !== 'string' || !value.password) return null; return { secret: Number(value.secret), password: value.password, link: typeof value.link === 'string' ? value.link : '', confirmed: Boolean(value.confirmed), }; } function bytesToString(bytes) { let text = ''; for (let index = 0; index < bytes.length; index += 1) text += String.fromCharCode(bytes[index]); return text; } function stringToBytes(text) { return Uint8Array.from(text, (character) => character.charCodeAt(0)); } function base64Encode(bytes) { return btoa(bytesToString(bytes)); } function base58Encode(bytes) { const alphabet = BASE58_ALPHABET; let value = 0n; for (let index = 0; index < bytes.length; index += 1) value = value * 256n + BigInt(bytes[index]); let encoded = ''; while (value > 0n) { encoded = alphabet[Number(value % 58n)] + encoded; value /= 58n; } for (let index = 0; index < bytes.length && bytes[index] === 0; index += 1) encoded = '1' + encoded; return encoded; } async function deflateBytes(bytes) { if (typeof CompressionStream !== 'function' || typeof Blob !== 'function') return null; try { const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); return new Uint8Array(await new Response(stream).arrayBuffer()); } catch (_) { return null; } } async function derivePasteKey(keyString, password, spec, subtle) { let keyBytes = stringToBytes(keyString); if (password.length > 0) { const passwordBytes = stringToBytes(password); const merged = new Uint8Array(keyBytes.length + passwordBytes.length); merged.set(keyBytes, 0); merged.set(passwordBytes, keyBytes.length); keyBytes = merged; } const imported = await subtle.importKey('raw', keyBytes, { name: 'PBKDF2' }, false, ['deriveKey']); return subtle.deriveKey( { name: 'PBKDF2', salt: stringToBytes(spec[1]), iterations: spec[2], hash: { name: 'SHA-256' } }, imported, { name: 'AES-' + spec[6].toUpperCase(), length: spec[3] }, false, ['encrypt'], ); } async function encryptPaste(message, password, randomSource = globalThis.crypto) { const subtle = randomSource && randomSource.subtle; if (!subtle || typeof randomSource.getRandomValues !== 'function' || typeof btoa !== 'function') { throw new Error('浏览器不支持所需的加密接口,请手动创建 privatebin。'); } const randomBytes = (length) => { const bytes = new Uint8Array(length); randomSource.getRandomValues(bytes); return bytes; }; const plaintext = new TextEncoder().encode(message); const compressed = await deflateBytes(plaintext); const iv = randomBytes(16); const salt = randomBytes(8); const spec = [ bytesToString(iv), bytesToString(salt), 100000, 256, 128, 'aes', 'gcm', compressed ? 'zlib' : 'none', ]; const encodedSpec = [ base64Encode(iv), base64Encode(salt), 100000, 256, 128, 'aes', 'gcm', spec[7], ]; const adata = [encodedSpec, PRIVATEBIN_FORMATTER, 0, 0]; const keyString = bytesToString(randomBytes(32)); const encrypted = await subtle.encrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(JSON.stringify(adata)), tagLength: 128, }, await derivePasteKey(keyString, password, spec, subtle), compressed || plaintext, ); return { keyString, data: { v: 2, ct: base64Encode(new Uint8Array(encrypted)), adata, meta: { expire: PRIVATEBIN_EXPIRE }, }, }; } function privateBinPasteUrl(id, keyString) { return `${PRIVATEBIN_SITE}?${id}#${base58Encode(stringToBytes(keyString))}`; } function base64Decode(text) { const binary = atob(text); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; } async function inflateBytes(bytes) { if (typeof DecompressionStream !== 'function' || typeof Blob !== 'function') return null; try { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); return new Uint8Array(await new Response(stream).arrayBuffer()); } catch (_) { return null; } } async function decipherPaste(keyString, password, data, subtle = globalThis.crypto && globalThis.crypto.subtle) { if (!subtle || typeof atob !== 'function') throw new Error('浏览器不支持所需的解密接口。'); const adataString = JSON.stringify(data[1]); const spec = (Array.isArray(data[1][0]) ? data[1][0] : data[1]).slice(); const iv = base64Decode(spec[0]); const salt = base64Decode(spec[1]); let keyBytes = stringToBytes(keyString); if (password.length > 0) { const passwordBytes = stringToBytes(password); const merged = new Uint8Array(keyBytes.length + passwordBytes.length); merged.set(keyBytes, 0); merged.set(passwordBytes, keyBytes.length); keyBytes = merged; } const imported = await subtle.importKey('raw', keyBytes, { name: 'PBKDF2' }, false, ['deriveKey']); const cipherKey = await subtle.deriveKey( { name: 'PBKDF2', salt, iterations: spec[2], hash: { name: 'SHA-256' } }, imported, { name: 'AES-' + spec[6].toUpperCase(), length: spec[3] }, false, ['decrypt'], ); const plain = new Uint8Array(await subtle.decrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(adataString), tagLength: spec[4], }, cipherKey, base64Decode(data[0]), )); if (spec[7] === 'zlib') { const inflated = await inflateBytes(plain); if (inflated) return new TextDecoder().decode(inflated); } return new TextDecoder().decode(plain); } const POST_SELECTOR = '.forum_post, .forum-post, table[id^="post"], article[id^="post"], div[id^="post"]'; const POST_BODY_SELECTOR = 'td.body, .post_body, .postbody, .post-content, .forum-post__body'; // 新增过滤论坛自带的编辑提示容器(如 .last_edited 等) const OMIT_POST_TEXT = 'blockquote, .quote, .blockquote, .signature, .sig, .post_signature, .post-signature, .last_edited, .last-edited, .edited_by, .post_edited, .post_actions, .post-actions, .forum_post_actions, [data-ggn-copy-toolbar], button, input, textarea, select, script, style, [hidden], [aria-hidden="true"]'; // 统一剥离帖子中的“Last edited by...”等论坛编辑尾注 function stripPostNoise(raw) { return String(raw ?? '') .replace(/\r\n?/g, '\n') .split('\n') .map((line) => line.replace(/\s*Last\s+edited\s+by\b.*$/i, '').trim()) .filter((line) => line && !/^Last\s+edited\s+by\b/i.test(line)) .join('\n') .trim(); } // 净化自定义回复行:去除论坛编辑痕迹,并去掉条件句末尾多余的问号(如将 "x < 850000?" 净化为 "x < 850000") function sanitizeCustomReplyLine(line) { const cleaned = String(line ?? '') .replace(/\s*Last\s+edited\s+by\b.*$/i, '') .trim(); if (!cleaned || /^Last\s+edited\s+by\b/i.test(cleaned)) return ''; return cleaned.replace(/[??]+$/g, '').trim(); } function sanitizeCustomReplyBlock(text) { return String(text ?? '') .replace(/\r\n?/g, '\n') .split('\n') .map(sanitizeCustomReplyLine) .filter(Boolean) .join('\n'); } function isPostContainer(node) { return node.matches('.forum_post, .forum-post') || /^post_?\d+$/i.test(node.id); } function closestPost(node) { let candidate = node.closest(POST_SELECTOR); while (candidate && !isPostContainer(candidate)) candidate = candidate.parentElement?.closest(POST_SELECTOR); return candidate || null; } function findPostBody(post) { const candidates = post.querySelectorAll(POST_BODY_SELECTOR); return Array.from(candidates).find((body) => closestPost(body) === post) || null; } function extractPostText(body) { const blocks = new Set(['P', 'DIV', 'LI', 'UL', 'OL', 'PRE', 'TABLE', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']); const parts = []; const view = body.ownerDocument.defaultView; function visit(node, preformatted = false) { if (node.nodeType === 3) { parts.push(preformatted ? node.nodeValue : node.nodeValue.replace(/\s+/g, ' ')); return; } if (node.nodeType !== 1 || node.matches(OMIT_POST_TEXT)) return; if (view && view.getComputedStyle(node).display === 'none') return; if (node.tagName === 'BR') { parts.push('\n'); return; } const block = blocks.has(node.tagName); if (block) parts.push('\n'); for (const child of node.childNodes) visit(child, preformatted || node.tagName === 'PRE'); if (block) parts.push('\n'); } visit(body); const rawText = parts.join('').replace(/\r\n?/g, '\n').split('\n') .map((line) => line.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim(); return stripPostNoise(rawText); } function installPostCopyButtons(doc, copyText, onCopied = null) { const view = doc.defaultView; let queued = false; let stopped = false; function scan() { for (const post of doc.querySelectorAll(POST_SELECTOR)) { if (!isPostContainer(post)) continue; const body = findPostBody(post); if (!body || post.querySelector('[data-ggn-copy-toolbar]')) continue; const toolbar = doc.createElement('span'); toolbar.setAttribute('data-ggn-copy-toolbar', ''); toolbar.style.cssText = 'display:inline-block;margin:0 6px;vertical-align:middle;'; const button = doc.createElement('button'); button.type = 'button'; button.textContent = '复制问题'; button.title = '复制正文并自动处理发言'; button.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 6px;cursor:pointer;'; toolbar.appendChild(button); const headerCell = Array.from(post.querySelectorAll('.colhead_dark td, .colhead td, .forum-post__heading, .post_header')) .find((node) => closestPost(node) === post && !body.contains(node)); const postIdLink = headerCell && Array.from(headerCell.querySelectorAll('a')) .find((a) => /^#\d+$/.test(a.textContent.trim()) || /^post\d+$/i.test(a.id)); const userLink = headerCell && headerCell.querySelector('a[href*="user.php"]'); if (postIdLink) { postIdLink.after(toolbar); } else if (userLink) { const userContainer = userLink.closest('strong, b') || userLink; userContainer.before(toolbar); } else if (headerCell) { headerCell.prepend(toolbar); } else { post.prepend(toolbar); } button.addEventListener('click', async () => { const currentBody = findPostBody(post); const text = currentBody ? extractPostText(currentBody) : ''; if (!text) { button.textContent = '没有可复制的正文'; return; } button.disabled = true; try { await copyText(text); } catch (_) { button.textContent = '复制失败,请手动复制'; button.disabled = false; return; } try { button.textContent = onCopied ? await onCopied(text) : '已复制'; button.title = '点击可再次复制并处理最新正文'; } catch (_) { button.textContent = '已复制,处理失败'; } finally { button.disabled = false; } }); } } scan(); const observer = new view.MutationObserver(() => { if (queued || stopped) return; queued = true; view.queueMicrotask(() => { queued = false; if (!stopped) scan(); }); }); observer.observe(doc.body, { childList: true, subtree: true }); return () => { stopped = true; observer.disconnect(); }; } function validateManualDigitSets(value, secret) { if (value === undefined || value === null) return true; if (!Array.isArray(value) || value.length !== 6) return false; const secretDigits = String(secret).padStart(6, '0').split('').map(Number); return value.every((digits, index) => ( Array.isArray(digits) && digits.length > 0 && digits.every((digit) => Number.isInteger(digit) && digit >= 0 && digit <= 9) && digits.includes(secretDigits[index]) )); } function clonePredicate(predicate) { return predicate ? JSON.parse(JSON.stringify(predicate)) : null; } function cloneEvent(event) { const cloned = { ...event }; if (event.predicate) cloned.predicate = clonePredicate(event.predicate); return cloned; } function migrateEvent(event) { const cloned = cloneEvent(event); if (typeof cloned.raw === 'string') { cloned.raw = stripPostNoise(cloned.raw); } if ((cloned.type === 'guess' || cloned.type === 'manualQuestion') && typeof cloned.raw === 'string') { const reparsedAction = parseAction(cloned.raw); if (reparsedAction.kind === 'question') { return { type: 'question', raw: sanitizeCustomReplyLine(cloned.raw), predicate: reparsedAction.predicate }; } } if (cloned.type === 'question' && typeof cloned.raw === 'string') { const reparsed = parseQuestion(cloned.raw); if (reparsed && (reparsed.type === 'digitParity' || reparsed.type === 'digitCompare' || reparsed.type === 'digitInSet' || reparsed.type === 'digitSumParity' || reparsed.type === 'digitsAllDifferent' || reparsed.type === 'range' || reparsed.type === 'lt' || reparsed.type === 'lte' || reparsed.type === 'gt' || reparsed.type === 'gte')) { cloned.predicate = reparsed; } } if (cloned.type === 'manualQuestion' && typeof cloned.raw === 'string') { cloned.raw = sanitizeCustomReplyLine(cloned.raw); } return cloned; } function validatePredicate(predicate) { if (!predicate || typeof predicate !== 'object') return false; const integerValue = isSafeInteger(predicate.value); switch (predicate.type) { case 'gt': case 'gte': case 'lt': case 'lte': case 'eq': case 'neq': return integerValue; case 'between': case 'outside': return integerValue && isSafeInteger(predicate.value2) && predicate.value <= predicate.value2; case 'range': return integerValue && isSafeInteger(predicate.value2) && predicate.value <= predicate.value2 && typeof predicate.includeValue === 'boolean' && typeof predicate.includeValue2 === 'boolean'; case 'parity': return predicate.value === 'even' || predicate.value === 'odd'; case 'digitSumParity': return predicate.value === 'even' || predicate.value === 'odd'; case 'digitParity': return DIGIT_DIVISORS.includes(predicate.divisor) && (predicate.value === 'even' || predicate.value === 'odd'); case 'digitCompare': return DIGIT_DIVISORS.includes(predicate.divisor) && ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].includes(predicate.operator) && integerValue && predicate.value >= 0 && predicate.value <= 9; case 'digitInSet': return DIGIT_DIVISORS.includes(predicate.divisor) && Array.isArray(predicate.digits) && predicate.digits.length > 0 && predicate.digits.every((digit) => Number.isInteger(digit) && digit >= 0 && digit <= 9) && new Set(predicate.digits).size === predicate.digits.length; case 'divisible': return integerValue && predicate.value > 0; case 'lastDigit': return integerValue && predicate.value >= 0 && predicate.value <= 9; case 'lastTwoDigits': return ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].includes(predicate.operator) && integerValue && predicate.value >= 0 && predicate.value <= 99; case 'contains': return typeof predicate.value === 'string' && /^\d+$/.test(predicate.value); case 'allDigitsSame': return true; case 'allDigitsUnique': return true; case 'digitsAllDifferent': return isSafeInteger(predicate.start) && isSafeInteger(predicate.length) && predicate.start >= 0 && predicate.length >= 2 && predicate.start + predicate.length <= 6; default: return false; } } function evaluatePredicate(number, predicate) { switch (predicate.type) { case 'gt': return number > predicate.value; case 'gte': return number >= predicate.value; case 'lt': return number < predicate.value; case 'lte': return number <= predicate.value; case 'eq': return number === predicate.value; case 'neq': return number !== predicate.value; case 'between': return number >= predicate.value && number <= predicate.value2; case 'outside': return number < predicate.value || number > predicate.value2; case 'range': { const aboveMinimum = predicate.includeValue ? number >= predicate.value : number > predicate.value; const belowMaximum = predicate.includeValue2 ? number <= predicate.value2 : number < predicate.value2; return aboveMinimum && belowMaximum; } case 'parity': return predicate.value === 'even' ? number % 2 === 0 : number % 2 !== 0; case 'digitParity': { const digit = Math.floor(number / predicate.divisor) % 10; return predicate.value === 'even' ? digit % 2 === 0 : digit % 2 !== 0; } case 'digitCompare': { const digit = Math.floor(number / predicate.divisor) % 10; const comparisons = { gt: digit > predicate.value, gte: digit >= predicate.value, lt: digit < predicate.value, lte: digit <= predicate.value, eq: digit === predicate.value, neq: digit !== predicate.value, }; return comparisons[predicate.operator]; } case 'digitInSet': { const digit = Math.floor(number / predicate.divisor) % 10; return predicate.digits.includes(digit); } case 'divisible': return number % predicate.value === 0; case 'lastDigit': return number % 10 === predicate.value; case 'lastTwoDigits': { const lastTwo = number % 100; const comparisons = { gt: lastTwo > predicate.value, gte: lastTwo >= predicate.value, lt: lastTwo < predicate.value, lte: lastTwo <= predicate.value, eq: digit === predicate.value, neq: lastTwo !== predicate.value, }; comparisons.eq = lastTwo === predicate.value; return comparisons[predicate.operator]; } case 'contains': return String(number).includes(predicate.value); case 'allDigitsSame': { const digits = String(number).padStart(6, '0'); return digits.split('').every((digit) => digit === digits[0]); } case 'allDigitsUnique': { const digits = String(number).padStart(6, '0'); return new Set(digits).size === 6; } case 'digitsAllDifferent': { const digits = String(number).padStart(6, '0'); const window = digits.slice(predicate.start, predicate.start + predicate.length); return new Set(window).size === window.length; } case 'digitSumParity': { const digits = String(number).padStart(6, '0'); let sum = 0; for (let index = 0; index < digits.length; index += 1) sum += Number(digits[index]); return predicate.value === 'even' ? sum % 2 === 0 : sum % 2 !== 0; } default: return false; } } function integerFromMatch(match, index = 1) { if (!match) return null; const value = Number(match[index]); return isSafeInteger(value) ? value : null; } function pairFromMatch(match) { if (!match) return null; const value = integerFromMatch(match, 1); const value2 = integerFromMatch(match, 2); return value === null || value2 === null ? null : { value, value2 }; } function parseDigitSetList(rawSet) { const cleaned = String(rawSet ?? '') .replace(/[{}[\]()]/g, '') .replace(/\s*(?:,|,|、|;|;|and\/or|\band\b|\bor\b|\+|,|+|和|或|及)\s*/gi, ' ') .trim(); if (!cleaned) return null; const parts = cleaned.split(/\s+/); if (!parts.length || parts.some((part) => !/^\d$/.test(part))) return null; return [...new Set(parts.map(Number))].sort((left, right) => left - right); } function allDifferentWindowPredicate(position, countToken) { const countWords = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, }; const count = countWords[countToken] ?? Number(countToken); if (!isSafeInteger(count) || count < 2 || count > 6) return null; return { type: 'digitsAllDifferent', start: position === 'start' ? 0 : 6 - count, length: count }; } function parseQuestion(input) { const text = stripPostNoise(input) .toLowerCase() .replace(/[??。.!!]+$/g, '') .replace(/≤/g, '<=') .replace(/≥/g, '>=') .replace(/(\d),(?=\d{3}(?:\D|$))/g, '$1') .replace(/\s+/g, ' ') .trim(); if (!text) return null; let match; let pair; let value; const compact = text.replace(/\s+/g, ''); const shorthandDivisors = { '100k': 100000, '10k': 10000, '1k': 1000, '100s': 100, '10s': 10, '1s': 1 }; match = compact.match(/^(100k|10k|1k|100s|10s|1s)(?:digit)?(?:is)?(odd|even)$/); if (match) { return { type: 'digitParity', divisor: shorthandDivisors[match[1]], value: match[2] }; } // 双边范围:例如 650000 < x < 850000 或 650000 <= n <= 850000 match = compact.match(/^(-?\d+)(<=|<)(?:x|n|num|number|it)(<=|<)(-?\d+)$/); if (match) { return { type: 'range', value: Number(match[1]), value2: Number(match[4]), includeValue: match[2] === '<=', includeValue2: match[3] === '<=', }; } match = compact.match(/^(-?\d+)(>=|>)(?:x|n|num|number|it)(>=|>)(-?\d+)$/); if (match) { return { type: 'range', value: Number(match[4]), value2: Number(match[1]), includeValue: match[3] === '>=', includeValue2: match[2] === '>=', }; } // 单边不等式:例如 x < 850000 match = compact.match(/^(?:x|n|num|number|it)(<=|>=|<|>|==|=|!=)(-?\d+)$/); if (match) { const symbolicTypes = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; return { type: symbolicTypes[match[1]], value: Number(match[2]) }; } match = compact.match(/^(-?\d+)(<=|>=|<|>|==|=|!=)(?:x|n|num|number|it)$/); if (match) { const reversedTypes = { '<': 'gt', '<=': 'gte', '>': 'lt', '>=': 'lte', '=': 'eq', '==': 'eq', '!=': 'neq' }; return { type: reversedTypes[match[2]], value: Number(match[1]) }; } // 宽松匹配:如果句子中包含 "is 650000 < x < 850000" 或 "is x < 850000" match = text.match(/(?:^|\b(?:is|if)\s+)(-?\d+)\s*(<=|<)\s*(?:x|n|num|number|it)\s*(<=|<)\s*(-?\d+)$/); if (match) { return { type: 'range', value: Number(match[1]), value2: Number(match[4]), includeValue: match[2] === '<=', includeValue2: match[3] === '<=', }; } match = text.match(/(?:^|\b(?:is|if)\s+)(-?\d+)\s*(>=|>)\s*(?:x|n|num|number|it)\s*(>=|>)\s*(-?\d+)$/); if (match) { return { type: 'range', value: Number(match[4]), value2: Number(match[1]), includeValue: match[3] === '>=', includeValue2: match[2] === '>=', }; } match = text.match(/(?:^|\b(?:is|if)\s+)(?:x|n|num|number|it)\s*(<=|>=|<|>|==|=|!=)\s*(-?\d+)$/); if (match) { const symbolicTypes = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; return { type: symbolicTypes[match[1]], value: Number(match[2]) }; } match = text.match(/\b(?:outside|not\s+between)\s+(?:the\s+range\s+)?(-?\d+)\s+(?:and|to)\s+(-?\d+)\b/); pair = pairFromMatch(match); if (pair) return { type: 'outside', ...pair }; match = text.match(/不在\s*(-?\d+)\s*(?:到|至|-)\s*(-?\d+)\s*(?:之间|范围)?|(?:区间外|范围外).*?(-?\d+)\s*(?:到|至|-)\s*(-?\d+)/); if (match) { const left = Number(match[1] ?? match[3]); const right = Number(match[2] ?? match[4]); if (isSafeInteger(left) && isSafeInteger(right)) return { type: 'outside', value: left, value2: right }; } match = text.match(/\bbetween\s+(-?\d+)\s+(?:and|to)\s+(-?\d+)\b/); pair = pairFromMatch(match); if (pair) return { type: 'between', ...pair }; match = text.match(/(?:在\s*)?(-?\d+)\s*(?:到|至|-)\s*(-?\d+)\s*(?:之间|范围内)/); pair = pairFromMatch(match); if (pair) return { type: 'between', ...pair }; const earlyPlaceDivisors = { '100k': 100000, '10k': 10000, '1k': 1000, '100s': 100, '10s': 10, '1s': 1, hundredthousand: 100000, hundredthousands: 100000, tenthousand: 10000, tenthousands: 10000, thousand: 1000, thousands: 1000, hundred: 100, hundreds: 100, ten: 10, tens: 10, one: 1, ones: 1, unit: 1, units: 1, }; const earlyPlacePattern = '(100k|10k|1k|100s|10s|1s|hundred[- ]?thousands?|ten[- ]?thousands?|thousands?|hundreds?|tens?|ones?|units?)'; match = text.match(new RegExp(`\\b(?:the\\s+)?${earlyPlacePattern}(?:\\s+digit)?\\s+(?:is\\s+)?(less\\s+than|greater\\s+than|at\\s+least|at\\s+most|equal\\s+to|not\\s+equal\\s+to)\\s*(\\d)\\b`)); if (match) { const textualComparisonTypes = { 'less than': 'lt', 'greater than': 'gt', 'at least': 'gte', 'at most': 'lte', 'equal to': 'eq', 'not equal to': 'neq', }; const key = match[1].replace(/[- ]/g, ''); return { type: 'digitCompare', divisor: earlyPlaceDivisors[key], operator: textualComparisonTypes[match[2]], value: Number(match[3]), }; } const setPlacePattern = '(100k|10k|1k|100s|10s|1s|hundred[- ]?thousands?|ten[- ]?thousands?|thousands?|hundreds?|tens?|ones?|units?)'; match = text.match(new RegExp(`\\b(?:the\\s+)?${setPlacePattern}(?:\\s+digit)?\\s*(?:is\\s+in|is\\s+one\\s+of|one\\s+of|among|in|is|are|equals?|∈)?\\s*[::]?\\s*\\{([^}]+)\\}`)); if (match) { const setDigits = parseDigitSetList(match[2]); const key = match[1].replace(/[- ]/g, ''); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: earlyPlaceDivisors[key], digits: setDigits }; } } match = text.match(new RegExp(`\\b(?:the\\s+)?${setPlacePattern}(?:\\s+digit)?\\s+(?:(?:is|are|equals?|is\\s+in|in)\\s+)?([0-9](?:[^0-9]{1,}[0-9]){1,})$`)); if (match) { const setDigits = parseDigitSetList(match[2]); const key = match[1].replace(/[- ]/g, ''); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: earlyPlaceDivisors[key], digits: setDigits }; } } match = text.match(new RegExp(`\\b(?:the\\s+)?${setPlacePattern}\\s*[::]\\s*([0-9](?:[^0-9]{1,}[0-9]){1,})$`)); if (match) { const setDigits = parseDigitSetList(match[2]); const key = match[1].replace(/[- ]/g, ''); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: earlyPlaceDivisors[key], digits: setDigits }; } } match = text.match(new RegExp(`\\b(?:the\\s+)?${setPlacePattern}\\s*[::]\\s*(\\d)$`)); if (match) { const key = match[1].replace(/[- ]/g, ''); return { type: 'digitCompare', divisor: earlyPlaceDivisors[key], operator: 'eq', value: Number(match[2]) }; } match = text.match(new RegExp(`\\b(?:are|is)\\s+([0-9](?:[^0-9]{1,}[0-9]){1,})\\s+(?:in|at)\\s+(?:the\\s+)?${setPlacePattern}(?:\\s+digit)?`)); if (match) { const setDigits = parseDigitSetList(match[1]); const key = match[2].replace(/[- ]/g, ''); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: earlyPlaceDivisors[key], digits: setDigits }; } } match = text.match(/\b(?:(?:it|the\s+number)\s+is\s+|is\s+(?:it|the\s+number)\s+)?(>=|<=|>|<|==|=|!=)\s*(-?\d+)\s*$/); if (match && !/\d/.test(text.slice(0, match.index))) { const symbolicTypes = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; return { type: symbolicTypes[match[1]], value: Number(match[2]) }; } const lastTwoPhrase = '(?:last|final|trailing)\\s+(?:two|2)\\s+digits?'; const lastTwoCn = '(最后|末尾|末|后)(?:两|2)位(?:数字)?'; const lastTwoSymbolic = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; const lastTwoWords = { 'at least': 'gte', 'no less than': 'gte', 'greater than or equal to': 'gte', 'at most': 'lte', 'no more than': 'lte', 'less than or equal to': 'lte', 'more than': 'gt', 'greater than': 'gt', 'above': 'gt', 'over': 'gt', 'less than': 'lt', 'below': 'lt', 'under': 'lt', 'equal to': 'eq', 'equals': 'eq', 'not equal to': 'neq', }; match = text.match(new RegExp(`\\b${lastTwoPhrase}\\s*(?:are|is)?\\s*(>=|<=|>|<|==|=|!=)\\s*(\\d{1,2})\\b`)); if (match) { return { type: 'lastTwoDigits', operator: lastTwoSymbolic[match[1]], value: Number(match[2]) }; } match = text.match(new RegExp(`\\b${lastTwoPhrase}\\s+(?:(?:are|is)\\s+)?(at\\s+least|at\\s+most|no\\s+more\\s+than|no\\s+less\\s+than|greater\\s+than\\s+or\\s+equal\\s+to|less\\s+than\\s+or\\s+equal\\s+to|more\\s+than|greater\\s+than|less\\s+than|above|below|over|under|equal\\s+to|equals|not\\s+equal\\s+to)\\s*(\\d{1,2})\\b`)); if (match) { return { type: 'lastTwoDigits', operator: lastTwoWords[match[1]], value: Number(match[2]) }; } match = text.match(new RegExp(`(>=|<=|>|<|==|=|!=)\\s*(\\d{1,2})\\s+(?:for|of)?\\s*${lastTwoPhrase}\\b`)); if (match) { return { type: 'lastTwoDigits', operator: lastTwoSymbolic[match[1]], value: Number(match[2]) }; } match = text.match(new RegExp(`\\b${lastTwoPhrase}\\s+(?:are|is)?\\s*(\\d{1,2})\\s+or\\s+(more|less|higher|lower)\\b`)); if (match) { return { type: 'lastTwoDigits', operator: ['more', 'higher'].includes(match[2]) ? 'gte' : 'lte', value: Number(match[1]) }; } match = text.match(new RegExp(`${lastTwoCn}\\s*(?:是|为)?\\s*(>=|<=|>|<|==|=|!=)\\s*(\\d{1,2})\\b`)); if (match) { return { type: 'lastTwoDigits', operator: lastTwoSymbolic[match[2]], value: Number(match[3]) }; } match = text.match(new RegExp(`${lastTwoCn}\\s*(?:是|为)?\\s*(不小于|不大于|大于等于|小于等于|大于|小于|至少|至多|等于|不等于)\\s*(\\d{1,2})\\b`)); if (match) { const lastTwoCnWords = { 不小于: 'gte', 不大于: 'lte', 大于等于: 'gte', 小于等于: 'lte', 大于: 'gt', 小于: 'lt', 至少: 'gte', 至多: 'lte', 等于: 'eq', 不等于: 'neq', }; return { type: 'lastTwoDigits', operator: lastTwoCnWords[match[2]], value: Number(match[3]) }; } const comparatorPatterns = [ ['gte', /\b(?:at\s+least|greater\s+than\s+or\s+equal\s+to|more\s+than\s+or\s+equal\s+to|no\s+less\s+than)\s*(-?\d+)\b/], ['gte', /(?:大于等于|不小于|至少)\s*(-?\d+)/], ['lte', /\b(?:at\s+most|less\s+than\s+or\s+equal\s+to|no\s+more\s+than)\s*(-?\d+)\b/], ['lte', /(?:小于等于|不大于|至多)\s*(-?\d+)/], ['gt', /\b(?:greater|more|larger|higher)\s+than\s*(-?\d+)\b/], ['gt', /\b(?:above|over)\s*(-?\d+)\b/], ['gt', /(?:大于|超过|高于)\s*(-?\d+)/], ['lt', /\b(?:less|smaller|lower|fewer)\s+than\s*(-?\d+)\b/], ['lt', /\b(?:below|under)\s*(-?\d+)\b/], ['lt', /(?:小于|少于|低于)\s*(-?\d+)/], ['neq', /\b(?:not\s+equal\s+to|does\s+not\s+equal)\s*(-?\d+)\b/], ['neq', /(?:不等于|不是)\s*(-?\d+)/], ]; for (const [type, pattern] of comparatorPatterns) { match = text.match(pattern); value = integerFromMatch(match); if (value !== null) return { type, value }; } match = text.match(/^(?:is\s+(?:it|the\s+number)\s+)?(?:equal\s+to|equals?)\s*(-?\d+)$/) || text.match(/^is\s+(?:it|the\s+number)\s+(-?\d+)$/) || text.match(/(?:等于|是不是|是否是)\s*(-?\d+)$/); value = integerFromMatch(match); if (value !== null) return { type: 'eq', value }; const placeDivisors = { '100k': 100000, '10k': 10000, '1k': 1000, '100s': 100, '10s': 10, '1s': 1, hundredthousand: 100000, hundredthousands: 100000, tenthousand: 10000, tenthousands: 10000, thousand: 1000, thousands: 1000, hundred: 100, hundreds: 100, ten: 10, tens: 10, one: 1, ones: 1, unit: 1, units: 1, }; const placePattern = '(100k|10k|1k|100s|10s|1s|hundred[- ]?thousands?|ten[- ]?thousands?|thousands?|hundreds?|tens?|ones?|units?)'; const digitComparisonTypes = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; match = text.match(new RegExp(`\\b(?:the\\s+)?${placePattern}(?:\\s+digit)?\\s*(<=|>=|<|>|==|=|!=)\\s*(\\d)\\b`)); if (match) { const key = match[1].replace(/[- ]/g, ''); return { type: 'digitCompare', divisor: placeDivisors[key], operator: digitComparisonTypes[match[2]], value: Number(match[3]) }; } match = text.match(new RegExp(`\\b(\\d)\\s*(<=|>=|<|>|==|=|!=)\\s*(?:the\\s+)?${placePattern}(?:\\s+digit)?\\b`)); if (match) { const reversedDigitTypes = { '<': 'gt', '<=': 'gte', '>': 'lt', '>=': 'lte', '=': 'eq', '==': 'eq', '!=': 'neq' }; const key = match[3].replace(/[- ]/g, ''); return { type: 'digitCompare', divisor: placeDivisors[key], operator: reversedDigitTypes[match[2]], value: Number(match[1]) }; } match = text.match(/(十万位|万位|千位|百位|十位|个位)(?:数字)?\s*(<=|>=|<|>|==|=|!=)\s*(\d)/); if (match) { const chineseDivisors = { 十万位: 100000, 万位: 10000, 千位: 1000, 百位: 100, 十位: 10, 个位: 1 }; return { type: 'digitCompare', divisor: chineseDivisors[match[1]], operator: digitComparisonTypes[match[2]], value: Number(match[3]) }; } match = text.match(new RegExp(`^(?:the\\s+)?${placePattern}(?:\\s+digit)?(?:\\s+(?:is|equals?|equal\\s+to))?\\s+(\\d)$`)); if (match) { const key = match[1].replace(/[- ]/g, ''); return { type: 'digitCompare', divisor: placeDivisors[key], operator: 'eq', value: Number(match[2]) }; } match = text.match(/^(十万位|万位|千位|百位|十位|个位)(?:数字)?(?:是|为|等于)?\s*(\d)$/); if (match) { const chineseDivisors = { 十万位: 100000, 万位: 10000, 千位: 1000, 百位: 100, 十位: 10, 个位: 1 }; return { type: 'digitCompare', divisor: chineseDivisors[match[1]], operator: 'eq', value: Number(match[2]) }; } match = text.match(/(十万位|万位|千位|百位|十位|个位)(?:数字?)?[::]?(?:是|为|等于|属于)?\s*\{([^}]+)\}/); if (match) { const chineseSetDivisors = { 十万位: 100000, 万位: 10000, 千位: 1000, 百位: 100, 十位: 10, 个位: 1 }; const setDigits = parseDigitSetList(match[2]); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: chineseSetDivisors[match[1]], digits: setDigits }; } } match = text.match(/(十万位|万位|千位|百位|十位|个位)(?:数字?)?[::]?(?:是|为|等于|属于)?\s*([0-9](?:[^0-9]{1,}[0-9]){1,})/); if (match) { const chineseSetDivisors = { 十万位: 100000, 万位: 10000, 千位: 1000, 百位: 100, 十位: 10, 个位: 1 }; const setDigits = parseDigitSetList(match[2]); if (setDigits && setDigits.length) { return { type: 'digitInSet', divisor: chineseSetDivisors[match[1]], digits: setDigits }; } } match = text.match(/\b(?:is\s+)?(?:the\s+)?digit\s+sum\s+(?:is\s+)?(odd|even)\b/) || text.match(/\b(?:is\s+)?(?:the\s+)?sum\s+of\s+(?:the\s+)?digits\s+(?:is\s+)?(odd|even)\b/) || text.match(/\b(?:is\s+)?(?:the\s+)?digits?\s+sum\s+(?:is\s+)?(odd|even)\b/) || text.match(/(?:各位)?(?:数字|数位)(?:的)?(?:和|总和|之和)(?:是|为)?(偶|奇)(?:数)?/) || text.match(/数字和(?:是|为)?(偶|奇)(?:数)?/); if (match) { if (match[1] === 'even' || match[1] === '偶') return { type: 'digitSumParity', value: 'even' }; return { type: 'digitSumParity', value: 'odd' }; } match = text.match(new RegExp(`\\b(?:is\\s+)?(?:the\\s+)?${placePattern}(?:\\s+digit)?\\s+(?:is\\s+)?(odd|even)\\b`)); if (match) { const key = match[1].replace(/[- ]/g, ''); return { type: 'digitParity', divisor: placeDivisors[key], value: match[2] }; } match = text.match(new RegExp(`\\b(odd|even)\\s+(?:the\\s+)?${placePattern}(?:\\s+digit)?\\b`)); if (match) { const key = match[2].replace(/[- ]/g, ''); return { type: 'digitParity', divisor: placeDivisors[key], value: match[1] }; } match = text.match(/(十万位|万位|千位|百位|十位|个位)(?:数字)?(?:是|为)?(奇|偶)(?:数)?/); if (match) { const chineseDivisors = { 十万位: 100000, 万位: 10000, 千位: 1000, 百位: 100, 十位: 10, 个位: 1 }; return { type: 'digitParity', divisor: chineseDivisors[match[1]], value: match[2] === '偶' ? 'even' : 'odd' }; } if (/\b(?:is\s+(?:it|the\s+number)\s+)?even\b/.test(text) || /(?:是|为)?偶数/.test(text)) { return { type: 'parity', value: 'even' }; } if (/\b(?:is\s+(?:it|the\s+number)\s+)?odd\b/.test(text) || /(?:是|为)?奇数/.test(text)) { return { type: 'parity', value: 'odd' }; } match = text.match(/\b(?:divisible|devisible|divisable|divided)\s+by\s+(-?\d+)\b/) || text.match(/(?:能否?|可以|可)?被\s*(-?\d+)\s*整除/); value = integerFromMatch(match); if (value !== null) return { type: 'divisible', value }; match = text.match(/\b(?:a\s+)?multiple\s+of\s+(-?\d+)\b/) || text.match(/是\s*(-?\d+)\s*的倍数/); value = integerFromMatch(match); if (value !== null) return { type: 'divisible', value }; match = text.match(/\b(?:last|units?|ones?)\s+digit\s+(?:is|equals?|equal\s+to)\s*(\d)\b/) || text.match(/\bends?\s+(?:in|with)\s+(\d)\b/) || text.match(/(?:末位|个位)(?:数字)?(?:是|为|等于)\s*(\d)/) || text.match(/以\s*(\d)\s*结尾/); value = integerFromMatch(match); if (value !== null) return { type: 'lastDigit', value }; match = text.match(/\bcontains?\s+(?:the\s+)?(?:digit|number)?\s*(\d+)\b/) || text.match(/包含(?:数字)?\s*(\d+)/); if (match) return { type: 'contains', value: match[1] }; const windowCount = '(one|two|three|four|five|six|\\d)'; const allDifferentTail = '(?:(?:are|is)?\\s*(?:all\\s*)?(?:different|distinct|unique|differ|non-?repeating)\\b|(?:have\\s+|with\\s+)?no\\s+(?:repeated|repeating|duplicate|repeats?|repetitions?)(?:\\s+digits?)?\\b)'; match = text.match(new RegExp(`\\b(?:the\\s+)?(?:last|final|trailing)\\s+${windowCount}\\s+digits?\\s+${allDifferentTail}`)); if (match) { const windowPredicate = allDifferentWindowPredicate('end', match[1]); if (windowPredicate) return windowPredicate; } match = text.match(new RegExp(`\\b(?:the\\s+)?(?:first|leading|leftmost)\\s+${windowCount}\\s+digits?\\s+${allDifferentTail}`)); if (match) { const windowPredicate = allDifferentWindowPredicate('start', match[1]); if (windowPredicate) return windowPredicate; } match = text.match(/(?:最后|末尾|末|后)([一二两三四五六\d])位(?:数字)?(?:(?:是否|是不是)\s*)?(?:(?:全都|全部|都|均|一律|全)\s*)?(?:互不相同|互相不同|彼此不同|各不相同|不相同|不同|不一样|不重复)/); if (match) { const windowPredicate = allDifferentWindowPredicate('end', match[1]); if (windowPredicate) return windowPredicate; } match = text.match(/(?:最前|开头|前|首)([一二两三四五六\d])位(?:数字)?(?:(?:是否|是不是)\s*)?(?:(?:全都|全部|都|均|一律|全)\s*)?(?:互不相同|互相不同|彼此不同|各不相同|不相同|不同|不一样|不重复)/); if (match) { const windowPredicate = allDifferentWindowPredicate('start', match[1]); if (windowPredicate) return windowPredicate; } if (/\b(?:are\s+|does\s+it\s+have\s+)?all\s+(?:the\s+)?digits\s+(?:are\s+)?(?:unique|different|distinct|differ)\b/.test(text) || /\b(?:has|have)\s+all\s+(?:unique|different|distinct)\s+digits\b/.test(text) || /\bno\s+(?:repeated|repeating|duplicate)\s+digits\b/.test(text) || /\bno\s+two\s+(?:of\s+the\s+)?digits\s+(?:are\s+)?(?:the\s+)?same\b/.test(text) || /(?:所有|全部)(?:位)?数字(?:都)?(?:互不相同|不同|不一样|不重复)/.test(text) || /每一位(?:数字)?(?:都)?(?:互不相同|不同|不一样)/.test(text) || /数字(?:都)?不重复/.test(text)) { return { type: 'allDigitsUnique' }; } if (/\b(?:are\s+)?all\s+(?:the\s+)?digits\s+(?:the\s+)?(?:same|identical)\b/.test(text) || /\b(?:do\s+)?all\s+(?:the\s+)?digits\s+match\b/.test(text) || /(?:所有|全部)(?:位)?数字(?:都)?(?:相同|一样)|数字是不是所有数字相同|每一位(?:数字)?(?:都)?(?:相同|一样)/.test(text)) { return { type: 'allDigitsSame' }; } return null; } // 支持把像 "100k: {7,8,9} ,x < 850000?" 或多行复合语句拆分为多个子条件依次解析 function parseCompoundQuestions(content) { // 将花括号外部的逗号、中文逗号、分号、换行拆开 const segments = []; let current = ''; let braceDepth = 0; for (let i = 0; i < content.length; i += 1) { const ch = content[i]; if (ch === '{' || ch === '[' || ch === '(') braceDepth += 1; else if (ch === '}' || ch === ']' || ch === ')') braceDepth = Math.max(0, braceDepth - 1); if (braceDepth === 0 && (ch === ',' || ch === ';' || ch === ';' || ch === '\n' || ch === ',')) { if (current.trim()) segments.push(current.trim()); current = ''; } else { current += ch; } } if (current.trim()) segments.push(current.trim()); if (segments.length <= 1) return null; const parsedList = []; for (const seg of segments) { const pred = parseQuestion(seg); if (!pred || !validatePredicate(pred)) return null; parsedList.push({ predicate: pred, raw: sanitizeCustomReplyLine(seg) }); } return parsedList.length >= 2 ? parsedList : null; } function parseAction(input) { const cleanedInput = stripPostNoise(input); if (!cleanedInput) return { kind: 'unknown', raw: '' }; const unquoted = cleanedInput .replace(/^["'‘’“”]+\s*/, '') .replace(/\s*["'‘’“”]+$/, '') .trim(); const uncommaed = unquoted.replace(/(\d),(?=\d{3}(?:\D|$))/g, '$1'); const leadingComparison = uncommaed.match(/^(<=|>=|<|>|==|=|!=)\s*(-?\d+)\s*[??]?$/); if (leadingComparison) { const comparisonTypes = { '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte', '=': 'eq', '==': 'eq', '!=': 'neq' }; const value = Number(leadingComparison[2]); const predicate = { type: comparisonTypes[leadingComparison[1]], value }; if (isSafeInteger(value) && validatePredicate(predicate)) { return { kind: 'question', raw: sanitizeCustomReplyLine(cleanedInput), predicate }; } } const content = uncommaed.replace(/^(?:>\s+)+/, '').trim(); let match = content.match(/^([+-]?\d+)\s*[??]?$/); if (!match) { match = content.match(/^(?:my\s+guess\s+is|i\s+guess|guess(?:ing)?|我猜(?:是)?|猜(?:测|数字)?(?:是)?)\s*[::]?\s*([+-]?\d+)\s*[??.!。!]?$/i); } if (match) { const value = Number(match[1]); return { kind: 'guess', raw: sanitizeCustomReplyLine(cleanedInput), value: isSafeInteger(value) ? value : Number.NaN }; } const predicate = parseQuestion(content); if (predicate && validatePredicate(predicate)) { return { kind: 'question', raw: sanitizeCustomReplyLine(cleanedInput), predicate }; } // 尝试解析复合语句(例如 "100k: {7,8,9} ,x < 850000?") const compound = parseCompoundQuestions(content); if (compound) { return { kind: 'compoundQuestion', raw: sanitizeCustomReplyLine(cleanedInput), items: compound }; } return { kind: 'unknown', raw: sanitizeCustomReplyLine(cleanedInput) }; } function createState(secret) { if (parseSecret(secret) === null) throw new Error(`秘密数字必须是 ${MIN_NUMBER}–${MAX_NUMBER} 的整数。`); return { schemaVersion: SCHEMA_VERSION, secret: Number(secret), customReply: '', manualDigitSets: null, hp: INITIAL_HP, status: 'active', winner: null, events: [], }; } function validateEvent(event) { if (!event || typeof event !== 'object') return false; if (event.type === 'guess') { return isSafeInteger(event.value) && event.value >= MIN_NUMBER && event.value <= MAX_NUMBER; } if (event.type === 'manualQuestion') return typeof event.raw === 'string' && event.raw.trim().length > 0; return event.type === 'question' && validatePredicate(event.predicate); } function validateState(value) { return Boolean( value && value.schemaVersion === SCHEMA_VERSION && parseSecret(value.secret) !== null && (value.customReply === undefined || typeof value.customReply === 'string') && validateManualDigitSets(value.manualDigitSets, value.secret) && Array.isArray(value.events) && value.events.every(validateEvent) ); } function predicateDescription(predicate) { const digitPlaceLabels = { 100000: '十万位', 10000: '万位', 1000: '千位', 100: '百位', 10: '十位', 1: '个位' }; const comparisonLabels = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠' }; const windowPlaceLabels = ['十万位', '万位', '千位', '百位', '十位', '个位']; const windowCountLabels = { 2: '两', 3: '三', 4: '四', 5: '五', 6: '六' }; const windowLabel = (start, length) => { if (start === 0 && length === 6) return '六个数位'; if (start + length === 6) return `末${windowCountLabels[length]}位(${windowPlaceLabels[start]}–个位)`; if (start === 0) return `前${windowCountLabels[length]}位(十万位–${windowPlaceLabels[length - 1]})`; return `${windowPlaceLabels[start]}–${windowPlaceLabels[start + length - 1]}(共 ${length} 位)`; }; const leftBoundary = predicate.includeValue ? '[' : '('; const rightBoundary = predicate.includeValue2 ? ']' : ')'; const labels = { gt: `大于 ${predicate.value}`, gte: `大于等于 ${predicate.value}`, lt: `小于 ${predicate.value}`, lte: `小于等于 ${predicate.value}`, eq: `等于 ${predicate.value}`, neq: `不等于 ${predicate.value}`, between: `在 ${predicate.value}–${predicate.value2} 闭区间内`, outside: `在 ${predicate.value}–${predicate.value2} 闭区间外`, range: `在 ${leftBoundary}${predicate.value}, ${predicate.value2}${rightBoundary} 区间内`, parity: predicate.value === 'even' ? '是偶数' : '是奇数', digitParity: `${digitPlaceLabels[predicate.divisor]}是${predicate.value === 'even' ? '偶数' : '奇数'}`, digitCompare: `${digitPlaceLabels[predicate.divisor]} ${comparisonLabels[predicate.operator]} ${predicate.value}`, divisible: `能被 ${predicate.value} 整除`, lastDigit: `末位是 ${predicate.value}`, contains: `十进制写法包含 ${predicate.value}`, lastTwoDigits: `末两位 ${comparisonLabels[predicate.operator]} ${predicate.value}`, allDigitsSame: '六个数位全部相同', allDigitsUnique: '六个数位互不相同', digitsAllDifferent: `${windowLabel(predicate.start, predicate.length)}互不相同`, digitSumParity: predicate.value === 'even' ? '各位数字之和是偶数' : '各位数字之和是奇数', }; if (predicate.type === 'digitInSet') { return `${digitPlaceLabels[predicate.divisor]} ∈ {${predicate.digits.join(',')}}`; } return labels[predicate.type] || '未知条件'; } function predicateCustomReply(predicate, answer) { if (predicate.type === 'divisible') return `Divisible by ${predicate.value}: ${answer ? 'Yes' : 'No'}`; if (predicate.type === 'allDigitsSame') return answer ? 'All digits same' : 'Not all digits same'; if (predicate.type === 'allDigitsUnique') return answer ? 'All digits unique' : 'Not all digits unique'; if (predicate.type === 'digitsAllDifferent') { const counts = { 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' }; const end = predicate.start + predicate.length; let label; if (end === 6 && predicate.length === 6) label = 'All digits'; else if (end === 6) label = `Last ${counts[predicate.length]} digits`; else if (predicate.start === 0) label = `First ${counts[predicate.length]} digits`; else label = `Digits ${predicate.start + 1}-${end}`; return `${label} all different: ${answer ? 'Yes' : 'No'}`; } return ''; } function predicateIdentity(predicate) { switch (predicate.type) { case 'gt': case 'gte': case 'lt': case 'lte': case 'eq': case 'neq': return `${predicate.type}:${predicate.value}`; case 'between': case 'outside': return `${predicate.type}:${predicate.value}:${predicate.value2}`; case 'range': return `${predicate.type}:${predicate.value}:${predicate.includeValue}:${predicate.value2}:${predicate.includeValue2}`; case 'parity': return `parity:${predicate.value}`; case 'digitParity': return `digitParity:${predicate.divisor}:${predicate.value}`; case 'digitCompare': return `digitCompare:${predicate.divisor}:${predicate.operator}:${predicate.value}`; case 'digitInSet': return `digitInSet:${predicate.divisor}:${predicate.digits.join(',')}`; case 'divisible': case 'lastDigit': case 'contains': return `${predicate.type}:${predicate.value}`; case 'lastTwoDigits': return `lastTwoDigits:${predicate.operator}:${predicate.value}`; case 'allDigitsSame': return 'allDigitsSame'; case 'allDigitsUnique': return 'allDigitsUnique'; case 'digitsAllDifferent': return `digitsAllDifferent:${predicate.start}:${predicate.length}`; case 'digitSumParity': return `digitSumParity:${predicate.value}`; default: return JSON.stringify(predicate); } } function questionsAreDuplicate(first, second) { if (!validatePredicate(first) || !validatePredicate(second)) return false; if (first.type === 'digitCompare' && second.type === 'digitCompare') { if (first.divisor !== second.divisor) return false; const inequalities = new Set(['gt', 'gte', 'lt', 'lte']); if (inequalities.has(first.operator) && inequalities.has(second.operator)) { return first.value === second.value; } const equalityPair = new Set([first.operator, second.operator]); return first.value === second.value && (first.operator === second.operator || (equalityPair.has('eq') && equalityPair.has('neq'))); } if (first.type === 'digitParity' && second.type === 'digitParity') { return first.divisor === second.divisor; } if (first.type === 'digitInSet' && second.type === 'digitInSet') { return first.divisor === second.divisor && first.digits.length === second.digits.length && first.digits.every((digit, index) => digit === second.digits[index]); } if ((first.type === 'allDigitsUnique' && second.type === 'digitsAllDifferent') || (first.type === 'digitsAllDifferent' && second.type === 'allDigitsUnique')) { const window = first.type === 'digitsAllDifferent' ? first : second; return window.start === 0 && window.length === 6; } if (first.type === 'parity' && second.type === 'parity') return true; if (first.type === 'digitSumParity' && second.type === 'digitSumParity') return true; if (predicateIdentity(first) === predicateIdentity(second)) return true; const complementaryTypes = { gt: 'lte', lte: 'gt', gte: 'lt', lt: 'gte', eq: 'neq', neq: 'eq', between: 'outside', outside: 'between', lastTwoDigits: 'lastTwoDigits', }; return complementaryTypes[first.type] === second.type && first.value === second.value && first.value2 === second.value2; } class GameEngine { constructor(state) { if (!validateState(state)) throw new Error('无法读取游戏状态。'); this.state = { schemaVersion: SCHEMA_VERSION, secret: Number(state.secret), customReply: typeof state.customReply === 'string' ? sanitizeCustomReplyBlock(state.customReply) : '', manualDigitSets: Array.isArray(state.manualDigitSets) ? state.manualDigitSets.map((digits) => [...new Set(digits)].sort((left, right) => left - right)) : null, hp: INITIAL_HP, status: 'active', winner: null, events: state.events.map(migrateEvent), }; this.candidates = new Uint8Array(CANDIDATE_COUNT); this.rebuild(); } rebuild() { this.candidates.fill(1); this.candidates[0] = 0; this.state.hp = INITIAL_HP; this.state.status = 'active'; this.state.winner = null; const validEvents = []; const acceptedQuestions = []; const acceptedManualQuestions = new Set(); for (const event of this.state.events) { if (this.state.status !== 'active') break; if (event.type === 'guess') { if (!this.candidates[event.value] || this.state.hp < GUESS_COST) continue; this.state.hp -= GUESS_COST; validEvents.push(event); if (event.value === this.state.secret) { this.state.status = 'finished'; this.state.winner = 'players'; } else { this.candidates[event.value] = 0; if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; } } } else if (event.type === 'manualQuestion') { const cleanRaw = sanitizeCustomReplyLine(event.raw); if (!cleanRaw) continue; event.raw = cleanRaw; const manualKey = cleanRaw.toLowerCase().replace(/\s+/g, ' '); if (acceptedManualQuestions.has(manualKey) || this.state.hp < QUESTION_COST) continue; this.state.hp -= QUESTION_COST; validEvents.push(event); acceptedManualQuestions.add(manualKey); if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; } } else if (event.type === 'question') { if (acceptedQuestions.some((predicate) => questionsAreDuplicate(predicate, event.predicate))) continue; if (!this.questionCanSplitCandidates(event.predicate)) continue; if (this.state.hp < QUESTION_COST) continue; const answer = evaluatePredicate(this.state.secret, event.predicate); this.filterCandidates(event.predicate, answer); this.state.hp -= QUESTION_COST; event.answer = answer; validEvents.push(event); acceptedQuestions.push(event.predicate); if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; } } } this.state.events = validEvents; this.applyManualDigitSets(); } applyManualDigitSets() { if (!this.state.manualDigitSets) return; const masks = this.state.manualDigitSets.map((digits) => { const mask = new Uint8Array(10); digits.forEach((digit) => { mask[digit] = 1; }); return mask; }); for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!this.candidates[number]) continue; for (let index = 0; index < DIGIT_DIVISORS.length; index += 1) { const digit = Math.floor(number / DIGIT_DIVISORS[index]) % 10; if (!masks[index][digit]) { this.candidates[number] = 0; break; } } } } filterCandidates(predicate, answer) { for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (this.candidates[number] && evaluatePredicate(number, predicate) !== answer) { this.candidates[number] = 0; } } } processInput(input) { const inactive = this.ensureActive(); if (inactive) return inactive; const action = parseAction(input); if (action.kind === 'guess') return this.processGuess(action.value, action.raw); if (action.kind === 'question') return this.processQuestion(action.predicate, action.raw); if (action.kind === 'compoundQuestion') { let anyChanged = false; let lastResult = null; const notices = []; for (const item of action.items) { const res = this.processQuestion(item.predicate, item.raw); lastResult = res; notices.push(res.notice); if (res.changed) anyChanged = true; } return { changed: anyChanged, notice: notices.join(' | '), tone: lastResult ? lastResult.tone : 'info', }; } if (action.raw) return this.processManualQuestion(action.raw); return { changed: false, notice: '无效操作:请输入玩家发言,不扣 HP。', tone: 'warning', }; } ensureActive() { if (this.state.status === 'active') return null; return { changed: false, notice: this.state.winner === 'players' ? '无效操作:游戏已经结束,玩家已获胜,不扣 HP。' : '无效操作:游戏已经结束,主持人已获胜,不扣 HP。', tone: 'warning', }; } processGuess(value, raw = String(value)) { const inactive = this.ensureActive(); if (inactive) return inactive; if (!isSafeInteger(value) || value < MIN_NUMBER || value > MAX_NUMBER) { return { changed: false, notice: `无效操作:猜测数字必须在 ${MIN_NUMBER}–${MAX_NUMBER} 之间,不扣 HP。`, tone: 'error' }; } if (!this.candidates[value]) { return { changed: false, notice: `无效操作(数字已排除):${value} 根据已有信息不可能正确,不扣 HP。`, tone: 'error' }; } this.state.hp -= GUESS_COST; const correct = value === this.state.secret; const event = { type: 'guess', value, correct, raw, answer: correct }; this.state.events.push(event); if (correct) { this.state.status = 'finished'; this.state.winner = 'players'; return { changed: true, notice: `有效操作:猜中 ${value},已扣 1 HP;玩家获胜。`, tone: 'success', event }; } this.candidates[value] = 0; if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; return { changed: true, notice: `有效操作:猜测 ${value} 错误,已扣 1 HP;HP 归零,主持人获胜。`, tone: 'success', event }; } return { changed: true, notice: `有效操作:猜测 ${value} 错误,已扣 1 HP。`, tone: 'info', event }; } processQuestion(predicate, raw = '') { const inactive = this.ensureActive(); if (inactive) return inactive; if (!validatePredicate(predicate)) { return { changed: false, notice: '无效操作:手动条件或参数无效,不扣 HP。', tone: 'error' }; } const duplicate = this.state.events.some((event) => ( event.type === 'question' && questionsAreDuplicate(event.predicate, predicate) )); if (duplicate) { const digitPlace = predicate.type === 'digitCompare' || predicate.type === 'digitParity'; return { changed: false, notice: digitPlace ? '无效操作(重复问题):该数位已经问过同类问题,不扣 HP。' : '无效操作(重复问题):该问题已经问过,不扣 HP。', tone: 'warning', }; } if (!this.questionCanSplitCandidates(predicate)) { return { changed: false, notice: '无效操作(答案已确定):该问题不能进一步缩小当前候选集,不扣 HP。', tone: 'warning', }; } if (this.state.hp < QUESTION_COST) { return { changed: false, notice: `无效操作:当前只有 ${this.state.hp} HP,不足 15 HP,不扣 HP。`, tone: 'error' }; } const cleanPredicate = clonePredicate(predicate); const answer = evaluatePredicate(this.state.secret, cleanPredicate); const filterBackup = this.candidates.slice(); this.filterCandidates(cleanPredicate, answer); if (!this.candidates[this.state.secret]) { this.candidates.set(filterBackup); return { changed: false, notice: `一致性检查失败:条件“${predicateDescription(cleanPredicate)}”的内部判断(${answer ? 'Yes' : 'No'})与秘密数字矛盾,已回滚该问题,不扣 HP。请核对问题表述,或改用手动问题处理。`, tone: 'error', }; } this.state.hp -= QUESTION_COST; const cleanRaw = sanitizeCustomReplyLine(raw); const event = { type: 'question', predicate: cleanPredicate, answer, raw: cleanRaw }; this.state.events.push(event); const customReplyLine = predicateCustomReply(cleanPredicate, answer); if (customReplyLine) { this.setCustomReply([this.state.customReply, customReplyLine].filter(Boolean).join('\n')); } if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; return { changed: true, notice: `有效操作:条件“${predicateDescription(cleanPredicate)}”内部判断 ${answer ? 'Yes' : 'No'}。已扣 15 HP,HP 归零:主持人获胜。秘密数字仍在候选中 ✓。`, tone: 'success', event, }; } return { changed: true, notice: `有效操作:条件“${predicateDescription(cleanPredicate)}”内部判断 ${answer ? 'Yes' : 'No'}。已扣 15 HP。秘密数字仍在候选中 ✓。`, tone: answer ? 'yes' : 'info', event, }; } processManualQuestion(raw) { const inactive = this.ensureActive(); if (inactive) return inactive; const cleanRaw = sanitizeCustomReplyBlock(raw); if (!cleanRaw) { return { changed: false, notice: '无效操作:请输入玩家发言,不扣 HP。', tone: 'warning' }; } const manualKey = cleanRaw.toLowerCase().replace(/\s+/g, ' '); const duplicate = this.state.events.some((event) => ( event.type === 'manualQuestion' && event.raw.trim().toLowerCase().replace(/\s+/g, ' ') === manualKey )); if (duplicate) { return { changed: false, notice: '无效操作(重复问题):该手动问题已经处理过,不扣 HP。', tone: 'warning' }; } if (this.state.hp < QUESTION_COST) { return { changed: false, notice: `无效操作:当前只有 ${this.state.hp} HP,不足 15 HP,不扣 HP。`, tone: 'error' }; } this.state.hp -= QUESTION_COST; this.setCustomReply([this.state.customReply, cleanRaw].filter(Boolean).join('\n')); const event = { type: 'manualQuestion', raw: cleanRaw }; this.state.events.push(event); if (this.state.hp === 0) { this.state.status = 'finished'; this.state.winner = 'host'; return { changed: true, notice: '有效操作(手动问题):已扣 15 HP;请在回复的 1s 后编辑内容。HP 归零,主持人获胜。候选集未改变,秘密数字仍在候选中 ✓。', tone: 'success', event, }; } return { changed: true, notice: '有效操作(手动问题):已扣 15 HP;请在回复的 1s 后编辑内容,后续会自动保留。候选集未改变,秘密数字仍在候选中 ✓。', tone: 'info', event, }; } setCustomReply(value) { this.state.customReply = sanitizeCustomReplyBlock(value); } setManualDigitSets(value) { if (!validateManualDigitSets(value, this.state.secret)) { return { changed: false, notice: '手动数位无效:每一位至少保留一个数字,且不能排除秘密数字。' }; } const normalized = value.map((digits) => [...new Set(digits)].sort((left, right) => left - right)); const isUnrestricted = normalized.every((digits) => digits.length === 10); this.state.manualDigitSets = isUnrestricted ? null : normalized; this.rebuild(); return { changed: true, notice: '手动数位已保存。' }; } questionCanSplitCandidates(predicate) { let hasYesCandidate = false; let hasNoCandidate = false; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!this.candidates[number]) continue; if (evaluatePredicate(number, predicate)) hasYesCandidate = true; else hasNoCandidate = true; if (hasYesCandidate && hasNoCandidate) return true; } return false; } undo() { if (!this.state.events.length) { return { changed: false, notice: '没有可撤销的有效操作。', tone: 'warning' }; } this.state.events.pop(); this.rebuild(); return { changed: true, notice: '已撤销最近一次有效操作。', tone: 'info' }; } candidateSummary() { const digitSeen = Array.from({ length: 6 }, () => new Uint8Array(10)); let count = 0; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!this.candidates[number]) continue; count += 1; for (let index = 0; index < DIGIT_DIVISORS.length; index += 1) { const digit = Math.floor(number / DIGIT_DIVISORS[index]) % 10; digitSeen[index][digit] = 1; } } const digits = digitSeen.map((seen) => { const values = []; for (let digit = 0; digit <= 9; digit += 1) { if (seen[digit]) values.push(digit); } return values; }); return { count, digits }; } secretIsCandidate() { return Boolean(this.candidates[this.state.secret]); } possibleNumbers() { const values = []; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (this.candidates[number]) values.push(number); } return values; } numericRangeSummary() { const rangeTypes = new Set(['gt', 'gte', 'lt', 'lte', 'between', 'outside', 'range']); const rangeEvents = this.state.events.filter((event) => ( event.type === 'question' && rangeTypes.has(event.predicate?.type) )); if (!rangeEvents.length) return null; const intervals = []; let intervalStart = null; for (let number = MIN_NUMBER; number <= MAX_NUMBER + 1; number += 1) { const possible = number <= MAX_NUMBER && rangeEvents.every((event) => ( evaluatePredicate(number, event.predicate) === event.answer )); if (possible && intervalStart === null) intervalStart = number; if (!possible && intervalStart !== null) { intervals.push([intervalStart, number - 1]); intervalStart = null; } } return intervals; } explicitAnswerLines() { const lines = []; for (const event of this.state.events) { if (event.type !== 'question') continue; const { predicate, answer } = event; if (predicate.type === 'digitSumParity') { const sumIsEven = predicate.value === 'even' ? answer : !answer; lines.push(sumIsEven ? 'Digit sum even' : 'Digit sum odd'); } } return lines; } formatOutput(summary = this.candidateSummary(), ranges = this.numericRangeSummary()) { const { digits } = summary; const lines = ['Possible digits', '']; for (let index = 0; index < DIGIT_LABELS.length; index += 1) { lines.push(`${DIGIT_LABELS[index]}: {${digits[index].join(',')}}`); } if (this.state.customReply) lines.push('', ...this.state.customReply.split('\n')); if (ranges) { const formattedRanges = ranges.map(([start, end]) => (start === end ? String(start) : `${start}-${end}`)); lines.push('', `Range: {${formattedRanges.join(',')}}`); } const explicitAnswers = this.explicitAnswerLines(); if (explicitAnswers.length) lines.push('', ...explicitAnswers); lines.push(''); const wrongGuesses = this.state.events .filter((event) => event.type === 'guess' && event.value !== this.state.secret) .map((event) => event.value); if (wrongGuesses.length) lines.push(`Not ${wrongGuesses.join(', ')}`); if (wrongGuesses.length) lines.push(''); lines.push(`HP: ${this.state.hp}/${INITIAL_HP}`); if (summary.count > 0) { lines.push('', `Possible: ${summary.count}`); if (summary.count <= CANDIDATE_LIST_LIMIT || this.state.hp <= ENDGAME_HP_THRESHOLD) { lines.push('', this.possibleNumbers().join(', ')); } } return lines.join('\n'); } exportState() { return { schemaVersion: SCHEMA_VERSION, secret: this.state.secret, customReply: this.state.customReply, manualDigitSets: this.state.manualDigitSets ? this.state.manualDigitSets.map((digits) => [...digits]) : null, hp: this.state.hp, status: this.state.status, winner: this.state.winner, events: this.state.events.map(cloneEvent), }; } } function shouldRun(urlValue) { try { const url = new URL(urlValue); return (url.hostname === 'gazellegames.net' || url.hostname === 'www.gazellegames.net') && url.pathname === '/forums.php' && url.searchParams.get('threadid') === '28282'; } catch (_) { return false; } } return { MIN_NUMBER, MAX_NUMBER, RANDOM_MIN_NUMBER, RANDOM_MAX_NUMBER, PRIVATEBIN_SITE, PRIVATEBIN_EXPIRE, encryptPaste, decipherPaste, privateBinPasteUrl, base58Encode, INITIAL_HP, QUESTION_COST, GUESS_COST, CANDIDATE_LIST_LIMIT, ENDGAME_HP_THRESHOLD, PASSWORD_LENGTH, SCHEMA_VERSION, parseSecret, randomSecret, randomPassword, startPostText, winPostText, parsePayload, stripPostNoise, sanitizeCustomReplyLine, sanitizeCustomReplyBlock, extractPostText, installPostCopyButtons, parseQuestion, parseAction, parseDigitSetList, validatePredicate, evaluatePredicate, predicateDescription, questionsAreDuplicate, createState, validateState, validateManualDigitSets, GameEngine, shouldRun, }; })(); if (typeof module !== 'undefined' && module.exports) { module.exports = GuessNumberHostCore; } if (typeof window === 'undefined' || typeof document === 'undefined') return; if (!GuessNumberHostCore.shouldRun(window.location.href)) return; const STORAGE_KEY = 'ggn-guess-number-host-state-v1'; const COLLAPSED_KEY = 'ggn-guess-number-host-collapsed-v1'; const PAYLOAD_KEY = 'ggn-guess-number-host-payload-v1'; let engine = null; let payload = null; let pasteAutoState = 'idle'; let pasteRequestId = 0; let lastNotice = { text: '输入秘密数字后点击“新游戏”,或点击“随机开始”。', tone: 'info' }; let manualVisible = false; function storageGet(key, fallback) { try { return typeof GM_getValue === 'function' ? GM_getValue(key, fallback) : fallback; } catch (_) { return fallback; } } function storageSet(key, value) { try { if (typeof GM_setValue === 'function') GM_setValue(key, value); } catch (_) { // 游戏仍可在当前页面继续;存储失败不会阻断主持操作。 } } const savedState = storageGet(STORAGE_KEY, null); if (GuessNumberHostCore.validateState(savedState)) { try { engine = new GuessNumberHostCore.GameEngine(savedState); lastNotice = { text: '已恢复上次游戏。', tone: 'info' }; } catch (_) { engine = null; } } payload = GuessNumberHostCore.parsePayload(storageGet(PAYLOAD_KEY, null)); if (payload && !payload.confirmed) { engine = null; lastNotice = { text: '已恢复待确认的本局设置,请填入 privatebin 链接并点击“确定”。', tone: 'info' }; } else if (payload && payload.confirmed && !engine) { payload = { ...payload, confirmed: false }; lastNotice = { text: '本局存档已失效,请重新填入 privatebin 链接并点击“确定”。', tone: 'warning' }; } const host = document.createElement('div'); host.id = 'ggn-guess-number-host'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` `; const $ = (selector) => shadow.querySelector(selector); const panel = $('#panel'); const launcher = $('#launcher'); const secretInput = $('#secret'); const messageInput = $('#message'); const output = $('#output'); const notice = $('#notice'); const manual = $('#manual'); const conditionType = $('#condition-type'); const manualFields = $('#manual-fields'); const passwordInput = $('#payload-password'); const linkInput = $('#payload-link'); const payloadHint = $('#payload-hint'); const confirmLinkButton = $('#confirm-link'); const winPost = $('#win-post'); const startPost = $('#start-post'); let customReplySaveTimer = null; let lastRenderedDigitSets = null; function saveGame() { if (engine) storageSet(STORAGE_KEY, engine.exportState()); } function savePayload() { storageSet(PAYLOAD_KEY, payload); } async function requestPasteJson(method, url, body) { const headers = { 'X-Requested-With': 'JSONHttpRequest' }; const options = { method, headers }; if (body !== undefined) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } try { const response = await fetch(url, options); return { status: response.status, text: await response.text() }; } catch (fetchError) { if (typeof GM_xmlhttpRequest !== 'function') throw fetchError; return await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data: options.body, timeout: 20000, onload: (response) => resolve({ status: response.status, text: response.responseText }), onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); } } async function createPrivateBinPaste(secret, password) { const message = JSON.stringify({ paste: String(secret) }); const { keyString, data } = await GuessNumberHostCore.encryptPaste(message, password); const response = await requestPasteJson('POST', GuessNumberHostCore.PRIVATEBIN_SITE, data); if (!response || response.status < 200 || response.status >= 300) { throw new Error(`服务返回 HTTP ${response && response.status}`); } let result; try { result = JSON.parse(response.text); } catch (_) { throw new Error('服务返回内容不是 JSON'); } if (!result || result.status !== 0 || !result.id) { throw new Error((result && result.message) || '服务拒绝了创建请求'); } const check = await requestPasteJson('GET', `${GuessNumberHostCore.PRIVATEBIN_SITE}?${result.id}`); let fetched; try { fetched = JSON.parse(check.text); } catch (_) { throw new Error('自检失败:读回内容不是 JSON'); } const plain = await GuessNumberHostCore .decipherPaste(keyString, password, [fetched.ct, fetched.adata]) .catch(() => { throw new Error('自检失败:读回的密文无法用本局口令解开'); }); if (plain !== message) throw new Error('自检失败:读回内容与上传内容不一致'); return GuessNumberHostCore.privateBinPasteUrl(result.id, keyString); } function setCollapsed(collapsed) { panel.classList.toggle('hidden', collapsed); launcher.classList.toggle('hidden', !collapsed); storageSet(COLLAPSED_KEY, collapsed); } function setNotice(text, tone = 'info') { lastNotice = { text, tone }; notice.textContent = text; notice.className = `notice ${tone}`; } function renderManualFields() { const type = conditionType.value; if (type === 'parity') { manualFields.innerHTML = '
'; return; } if (type === 'digitParity') { manualFields.innerHTML = ` `; return; } if (type === 'digitCompare') { manualFields.innerHTML = ` `; return; } if (type === 'digitInSet') { manualFields.innerHTML = ` `; return; } if (type === 'digitsAllDifferent') { manualFields.innerHTML = ` `; return; } const second = type === 'between' || type === 'outside'; const contains = type === 'contains'; manualFields.innerHTML = ` ${second ? '' : ''} `; } function readManualPredicate() { const type = conditionType.value; if (type === 'parity') return { type, value: $('#manual-parity').value }; if (type === 'digitParity') { return { type, divisor: Number($('#manual-place').value), value: $('#manual-parity').value }; } if (type === 'digitCompare') { const valueText = $('#manual-value').value.trim(); if (!/^\d$/.test(valueText)) return null; return { type, divisor: Number($('#manual-place').value), operator: $('#manual-operator').value, value: Number(valueText), }; } if (type === 'digitInSet') { const digits = GuessNumberHostCore.parseDigitSetList($('#manual-value').value); if (!digits || !digits.length) return null; return { type, divisor: Number($('#manual-place').value), digits }; } if (type === 'digitsAllDifferent') { return { type, start: Number($('#manual-window-start').value), length: Number($('#manual-window-length').value), }; } const firstText = $('#manual-value').value.trim(); if (type === 'contains') return { type, value: firstText }; if (!/^-?\d+$/.test(firstText)) return null; const value = Number(firstText); if (!Number.isSafeInteger(value)) return null; if (type === 'between' || type === 'outside') { const secondText = $('#manual-value2').value.trim(); if (!/^-?\d+$/.test(secondText)) return null; const value2 = Number(secondText); if (!Number.isSafeInteger(value2)) return null; return { type, value, value2 }; } return { type, value }; } function historyText(event) { if (event.type === 'guess') return `猜 ${event.value} → ${event.correct ? '正确' : '错误'}(-1 HP)`; if (event.type === 'manualQuestion') return `手动问题:${event.raw}(-15 HP)`; return `${GuessNumberHostCore.predicateDescription(event.predicate)} → ${event.answer ? 'Yes' : 'No'}(-15 HP)`; } function extractCustomReply(text) { const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n'); const digitLineIndex = lines.findIndex((line) => line.startsWith('1s:')); if (digitLineIndex < 0) return engine?.state.customReply || ''; let endIndex = lines.length; for (let index = digitLineIndex + 1; index < lines.length; index += 1) { if (/^(Range:|Digit sum\s|Not\s|HP:|Possible:|PrivateBin:)/.test(lines[index])) { endIndex = index; break; } } const customLines = lines.slice(digitLineIndex + 1, endIndex); return GuessNumberHostCore.sanitizeCustomReplyBlock(customLines.join('\n')); } function extractManualDigitSets(text) { const labels = ['100k', '10k', '1k', '100s', '10s', '1s']; const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n'); const sets = []; let inlineExtraConditions = []; for (const label of labels) { const line = lines.find((candidate) => candidate.trim().startsWith(`${label}:`)); if (!line) return null; const match = line.match(/\{([^}]*)\}(.*)$/); if (!match) return null; const parts = match[1].split(',').map((part) => part.trim()).filter(Boolean); if (!parts.length || parts.some((part) => !/^\d$/.test(part))) return null; sets.push([...new Set(parts.map(Number))].sort((left, right) => left - right)); // 如果用户在数位行后面追加了像 ",x < 850000? Last edited by..." 这样的内容,提取并净化 const trailing = match[2] ? match[2].replace(/^[,\s,;;]+/, '').trim() : ''; const cleanTrailing = GuessNumberHostCore.sanitizeCustomReplyLine(trailing); if (cleanTrailing) { inlineExtraConditions.push(cleanTrailing); } } return { sets, inlineExtraConditions }; } function syncEditableOutput(showError = false) { if (!engine) return false; const extracted = extractManualDigitSets(output.value); if (!extracted) { if (showError) setNotice('手动数位格式无效,请保持 {数字,数字} 格式;未保存,也未处理新问题。', 'error'); return false; } const { sets: digitSets, inlineExtraConditions } = extracted; let customReplyText = extractCustomReply(output.value); // 如果用户在某一行数位后面直接写了额外条件(例如 "100k: {7,8,9} ,x < 850000?"), // 自动将其解析为有效范围条件来过滤候选集,或净化后保留 for (const condText of inlineExtraConditions) { const pred = GuessNumberHostCore.parseQuestion(condText); if (pred && GuessNumberHostCore.validatePredicate(pred)) { const answer = GuessNumberHostCore.evaluatePredicate(engine.state.secret, pred); engine.filterCandidates(pred, answer); } if (!customReplyText.split('\n').includes(condText)) { customReplyText = [customReplyText, condText].filter(Boolean).join('\n'); } } engine.setCustomReply(customReplyText); const digitsChanged = !lastRenderedDigitSets || digitSets.some((digits, index) => ( digits.length !== lastRenderedDigitSets[index].length || digits.some((digit, digitIndex) => digit !== lastRenderedDigitSets[index][digitIndex]) )); if (!digitsChanged) { saveGame(); return true; } const result = engine.setManualDigitSets(digitSets); if (!result.changed) { if (showError) setNotice(`${result.notice} 未保存,也未处理新问题。`, 'error'); return false; } lastRenderedDigitSets = digitSets.map((digits) => [...digits]); saveGame(); return true; } function renderPayload() { if (!payload) { passwordInput.value = ''; linkInput.value = ''; linkInput.disabled = true; confirmLinkButton.disabled = true; payloadHint.textContent = '点击“随机开始”或“新游戏”后,这里会生成 10 位口令并等待 privatebin 链接。'; return; } passwordInput.value = payload.password; if (shadow.activeElement !== linkInput) linkInput.value = payload.link; linkInput.disabled = payload.confirmed; confirmLinkButton.disabled = payload.confirmed; if (payload.confirmed) { payloadHint.textContent = '本局已正式开始,链接已记录。'; return; } const autoHints = { creating: `正在自动创建 privatebin(内容为本局秘密数字,${GuessNumberHostCore.PRIVATEBIN_EXPIRE.replace('day', ' 天').replace('hour', ' 小时')}后自动过期)…`, created: '已自动创建 privatebin 并自检通过,核对后点击“确定”即可开始本轮。', failed: '自动创建失败,请手动创建 privatebin(用上面的口令保存数字),把链接粘贴进来后点击“确定”。', idle: '把口令填入 privatebin 保存好数字,再把链接粘贴到这里并点击“确定”,本轮才开始。', }; payloadHint.textContent = autoHints[pasteAutoState] || autoHints.idle; } function renderPosts() { const ready = Boolean(payload && payload.confirmed); startPost.value = ready ? GuessNumberHostCore.startPostText(payload.link) : ''; winPost.value = ready ? GuessNumberHostCore.winPostText(payload.secret, payload.password, payload.link) : ''; } function render() { renderPayload(); renderPosts(); secretInput.value = engine ? String(engine.state.secret) : (payload && !payload.confirmed ? String(payload.secret) : ''); $('#hp').textContent = engine ? `${engine.state.hp}/${GuessNumberHostCore.INITIAL_HP}` : '—'; $('#process').disabled = !engine; $('#copy').disabled = !engine; output.disabled = !engine; $('#undo').disabled = !engine || engine.state.events.length === 0; if (!engine) { lastRenderedDigitSets = null; $('#candidate-count').textContent = '—'; $('#secret-check').textContent = '—'; $('#secret-metric').className = 'metric'; output.value = ''; $('#history').innerHTML = '