// ==UserScript== // @name GGN 猜数字玩家助手 // @namespace https://gazellegames.net/ // @version 1.0.0 // @description 为指定 GGN 主题提供猜数字玩家面板、主持汇总解析、最优二分问题推荐、6大规则防呆校验与一键填入回复框。 // @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 GuessNumberPlayerCore = (() => { const MIN_NUMBER = 1; const MAX_NUMBER = 999999; const CANDIDATE_COUNT = MAX_NUMBER + 1; const INITIAL_HP = 250; const QUESTION_COST = 15; const GUESS_COST = 1; const CANDIDATE_CHIP_LIMIT = 100; const SCHEMA_VERSION = 1; const DIGIT_LABELS = ['100k', '10k', '1k', '100s', '10s', '1s']; const DIGIT_DIVISORS = [100000, 10000, 1000, 100, 10, 1]; const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const PRIVATEBIN_SITE = 'https://privatebin.net/'; function isSafeInteger(value) { return Number.isSafeInteger(value); } function stringToBytes(text) { return Uint8Array.from(text, (character) => character.charCodeAt(0)); } function bytesToString(bytes) { let text = ''; for (let index = 0; index < bytes.length; index += 1) text += String.fromCharCode(bytes[index]); return text; } 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; } function base58Decode(text) { const alphabet = BASE58_ALPHABET; let value = 0n; for (let i = 0; i < text.length; i += 1) { const index = alphabet.indexOf(text[i]); if (index === -1) throw new Error('无效的 Base58 字符'); value = value * 58n + BigInt(index); } const bytes = []; while (value > 0n) { bytes.unshift(Number(value % 256n)); value /= 256n; } for (let i = 0; i < text.length && text[i] === '1'; i += 1) { bytes.unshift(0); } return new Uint8Array(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'; 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-player-toolbar], [data-ggn-copy-toolbar], button, input, textarea, select, script, style, [hidden], [aria-hidden="true"]'; 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(); } 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 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 fillQuickReply(doc, text, autoFocus = true) { const selectors = [ '#quickpost', 'textarea[name="body"]', '#quickreplytext textarea', '#reply_box textarea', 'form[action*="forums.php"] textarea', ]; for (const sel of selectors) { const box = doc.querySelector(sel); if (box) { box.value = text; box.dispatchEvent(new Event('input', { bubbles: true })); box.dispatchEvent(new Event('change', { bubbles: true })); if (autoFocus) { box.scrollIntoView({ behavior: 'smooth', block: 'center' }); box.focus(); } return true; } } return false; } 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': 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': 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: lastTwo === predicate.value, neq: 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] }; } 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] === '>=', }; } 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]) }; } 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 = earlyPlacePattern; 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(`${lastTwoCn}\\s*(?:是|为)?\\s*(>=|<=|>|<|==|=|!=)\\s*(\\d{1,2})\\b`)); if (match) { return { type: 'lastTwoDigits', operator: lastTwoSymbolic[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 = earlyPlaceDivisors; const placePattern = earlyPlacePattern; 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(/\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(/(十万位|万位|千位|百位|十位|个位)(?:数字)?(?:是|为)?(奇|偶)(?:数)?/); 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; } // Rule 6: 检测是否为违规的多合一复合问题 (如 "Is x/3 = 0 or is x/9 = 1?" 或逗号分隔的多条件) function detectMultiPartViolation(rawInput) { const cleaned = stripPostNoise(rawInput); if (!cleaned) return false; // 移除花括号集合内容如 {1,2,3} 再检测 const withoutBraces = cleaned.replace(/\{[^}]*\}/g, '{SET}'); // 包含多个问号 if ((withoutBraces.match(/[??]/g) || []).length >= 2) return true; // 包含 "or is", "and is", "&&", "||" if (/\b(?:or|and)\s+(?:is|are|does|do|has|have|can)\b/i.test(withoutBraces)) return true; if (/(\band\b|\bor\b|&&|\|\||或者|并且|同时)/i.test(withoutBraces)) { // 排除合法的 between X and Y / greater than or equal to / 1, 2 or 3 in 100k const safeBetween = /\bbetween\s+-?\d+\s+and\s+-?\d+\b/i.test(withoutBraces); const safeOrEqual = /\b(?:greater|less|more|fewer)\s+than\s+or\s+equal\s+to\b/i.test(withoutBraces); const safeDigitList = /\b\d(?:\s*,\s*\d)*\s+(?:and|or)\s+\d\b/i.test(withoutBraces); if (!safeBetween && !safeOrEqual && !safeDigitList) return true; } // 检测花括号外部的逗号/分号拆出多个合法问题 const segments = withoutBraces.split(/[,,;;\n]+/).map((s) => s.trim()).filter(Boolean); if (segments.length >= 2) { let validCount = 0; for (const seg of segments) { if (parseQuestion(seg)) validCount += 1; } if (validCount >= 2) return true; } return false; } function parseAction(input) { const cleanedInput = stripPostNoise(input); if (!cleanedInput) return { kind: 'empty', raw: '' }; if (detectMultiPartViolation(cleanedInput)) { return { kind: 'multipart', raw: sanitizeCustomReplyLine(cleanedInput) }; } 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 }; } return { kind: 'unknown', raw: sanitizeCustomReplyLine(cleanedInput) }; } 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 parseHostSummaryText(rawText) { const text = stripPostNoise(rawText); const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); // 判断是否是 New Round 开局贴 if (/New\s+Round!/i.test(text) && !/Possible\s+digits/i.test(text)) { const linkMatch = text.match(/https?:\/\/privatebin\.net\/\?\S+/i); return { isNewRound: true, digitSets: Array.from({ length: 6 }, () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), ranges: null, wrongGuesses: [], conditions: [], hp: INITIAL_HP, privateBinUrl: linkMatch ? linkMatch[0] : '', rawText: text, }; } if (!/Possible\s+digits/i.test(text) && !/HP:\s*\d+\s*\/\s*\d+/i.test(text)) { return null; } const digitSets = []; for (const label of DIGIT_LABELS) { const line = lines.find((l) => l.toLowerCase().startsWith(`${label.toLowerCase()}:`)); if (!line) { digitSets.push([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); continue; } const match = line.match(/\{([^}]*)\}/); if (!match) { digitSets.push([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); continue; } const parsed = parseDigitSetList(match[1]); digitSets.push(parsed && parsed.length ? parsed : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); } let ranges = null; const rangeLine = lines.find((l) => /^Range:\s*\{/i.test(l)); if (rangeLine) { const match = rangeLine.match(/\{([^}]*)\}/); if (match) { const parsedRanges = []; const parts = match[1].split(',').map((s) => s.trim()).filter(Boolean); for (const part of parts) { const rMatch = part.match(/^(\d+)(?:-(\d+))?$/); if (rMatch) { const start = Number(rMatch[1]); const end = rMatch[2] !== undefined ? Number(rMatch[2]) : start; if (start <= end) parsedRanges.push([start, end]); } } if (parsedRanges.length) ranges = parsedRanges; } } const wrongGuesses = []; const notLine = lines.find((l) => /^Not\s+\d/i.test(l)); if (notLine) { const nums = notLine.replace(/^Not\s+/i, '').split(',').map((s) => Number(s.trim())); for (const n of nums) { if (isSafeInteger(n) && n >= MIN_NUMBER && n <= MAX_NUMBER) wrongGuesses.push(n); } } let hp = INITIAL_HP; const hpLine = lines.find((l) => /^HP:\s*\d+/i.test(l)); if (hpLine) { const m = hpLine.match(/^HP:\s*(\d+)/i); if (m) hp = Number(m[1]); } const conditions = []; for (const line of lines) { if (/^(Possible\s+digits|100k:|10k:|1k:|100s:|10s:|1s:|Range:|Not\s+|HP:|Possible:|PrivateBin:)/i.test(line)) { continue; } if (/^\d+(?:,\s*\d+)+$/.test(line)) continue; // 跳过候选数字枚举列表行 let m = line.match(/^Divisible\s+by\s+(\d+):\s*(Yes|No)$/i); if (m) { conditions.push({ predicate: { type: 'divisible', value: Number(m[1]) }, answer: m[2].toLowerCase() === 'yes', raw: line, }); continue; } if (/^All\s+digits\s+same$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsSame' }, answer: true, raw: line }); continue; } if (/^Not\s+all\s+digits\s+same$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsSame' }, answer: false, raw: line }); continue; } if (/^All\s+digits\s+unique$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsUnique' }, answer: true, raw: line }); continue; } if (/^Not\s+all\s+digits\s+unique$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsUnique' }, answer: false, raw: line }); continue; } if (/^Digit\s+sum\s+even$/i.test(line)) { conditions.push({ predicate: { type: 'digitSumParity', value: 'even' }, answer: true, raw: line }); continue; } if (/^Digit\s+sum\s+odd$/i.test(line)) { conditions.push({ predicate: { type: 'digitSumParity', value: 'odd' }, answer: true, raw: line }); continue; } m = line.match(/^(All|First\s+\w+|Last\s+\w+)\s+digits\s+all\s+different:\s*(Yes|No)$/i); if (m) { const pred = parseQuestion(`${m[1]} digits all different`); if (pred) { conditions.push({ predicate: pred, answer: m[2].toLowerCase() === 'yes', raw: line }); continue; } } // 兼容形如 "Question: Yes/No" m = line.match(/^(.+?)\s*[::]\s*(Yes|No|True|False)$/i); if (m) { const pred = parseQuestion(m[1]); if (pred && validatePredicate(pred)) { const ans = /^(yes|true)$/i.test(m[2]); conditions.push({ predicate: pred, answer: ans, raw: line }); continue; } } // 直接写一行的条件句(默认为 True) const directPred = parseQuestion(line); if (directPred && validatePredicate(directPred)) { conditions.push({ predicate: directPred, answer: true, raw: line }); } } const pbMatch = text.match(/https?:\/\/privatebin\.net\/\?\S+/i); return { isNewRound: false, digitSets, ranges, wrongGuesses, conditions, hp, privateBinUrl: pbMatch ? pbMatch[0] : '', rawText: text, }; } // 解析主持人的 Win! 结束贴 function parseWinPostText(rawText) { const text = stripPostNoise(rawText); const numMatch = text.match(/The\s+number\s+was\s+(\d{1,6})\b/i); const passMatch = text.match(/The\s+password\s+was\s+([A-Za-z0-9]+)\b/i); const linkMatch = text.match(/https?:\/\/privatebin\.net\/\?([a-f0-9]+)#([1-9A-HJ-NP-Za-km-z]+)/i); if (!numMatch || !passMatch) return null; return { secret: Number(numMatch[1]), password: passMatch[1], pasteId: linkMatch ? linkMatch[1] : '', keyBase58: linkMatch ? linkMatch[2] : '', fullLink: linkMatch ? linkMatch[0] : '', }; } class PlayerEngine { constructor(state = null) { this.candidates = new Uint8Array(CANDIDATE_COUNT); this.baseState = state && state.baseState ? state.baseState : { digitSets: Array.from({ length: 6 }, () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), ranges: null, wrongGuesses: [], conditions: [], hp: INITIAL_HP, privateBinUrl: '', rawText: '', syncedPostId: '', }; this.assumptions = state && Array.isArray(state.assumptions) ? state.assumptions : []; this.rebuild(); } syncFromHostParsed(parsed, postId = '') { this.baseState = { digitSets: parsed.digitSets, ranges: parsed.ranges, wrongGuesses: parsed.wrongGuesses, conditions: parsed.conditions, hp: parsed.hp, privateBinUrl: parsed.privateBinUrl || this.baseState.privateBinUrl || '', rawText: parsed.rawText || '', syncedPostId: postId || this.baseState.syncedPostId || '', }; this.assumptions = []; this.rebuild(); } rebuild() { this.candidates.fill(1); this.candidates[0] = 0; this.effectiveHp = this.baseState.hp; // 1. 应用数位集合过滤 if (Array.isArray(this.baseState.digitSets) && this.baseState.digitSets.length === 6) { const masks = this.baseState.digitSets.map((digits) => { const mask = new Uint8Array(10); digits.forEach((d) => { mask[d] = 1; }); return mask; }); for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { for (let idx = 0; idx < DIGIT_DIVISORS.length; idx += 1) { const digit = Math.floor(number / DIGIT_DIVISORS[idx]) % 10; if (!masks[idx][digit]) { this.candidates[number] = 0; break; } } } } // 2. 应用 Range 区间过滤 if (Array.isArray(this.baseState.ranges) && this.baseState.ranges.length > 0) { const rangeMask = new Uint8Array(CANDIDATE_COUNT); for (const [start, end] of this.baseState.ranges) { const s = Math.max(MIN_NUMBER, start); const e = Math.min(MAX_NUMBER, end); for (let n = s; n <= e; n += 1) rangeMask[n] = 1; } for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!rangeMask[number]) this.candidates[number] = 0; } } // 3. 排除已猜错的数字 if (Array.isArray(this.baseState.wrongGuesses)) { for (const wrong of this.baseState.wrongGuesses) { if (wrong >= MIN_NUMBER && wrong <= MAX_NUMBER) { this.candidates[wrong] = 0; } } } // 4. 应用主持人已确认的条件 if (Array.isArray(this.baseState.conditions)) { for (const cond of this.baseState.conditions) { if (validatePredicate(cond.predicate)) { this.filterCandidates(cond.predicate, Boolean(cond.answer)); } } } // 5. 应用玩家本地推演 / 楼下待结算的假设 for (const item of this.assumptions) { if (item.type === 'guess') { if (item.value >= MIN_NUMBER && item.value <= MAX_NUMBER && this.candidates[item.value]) { this.candidates[item.value] = 0; this.effectiveHp = Math.max(0, this.effectiveHp - GUESS_COST); } } else if (item.type === 'question' && validatePredicate(item.predicate)) { if (typeof item.answer === 'boolean') { this.filterCandidates(item.predicate, item.answer); } this.effectiveHp = Math.max(0, this.effectiveHp - QUESTION_COST); } } } 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; } } } whyNumberExcluded(number) { if (!isSafeInteger(number) || number < MIN_NUMBER || number > MAX_NUMBER) { return `超出合法范围 (${MIN_NUMBER}–${MAX_NUMBER})`; } if (this.baseState.wrongGuesses.includes(number)) { return '该数字此前已被猜过且排除 (在 Not 列表中)'; } for (const item of this.assumptions) { if (item.type === 'guess' && item.value === number) { return '该数字在待结算/本地推演中已被猜过'; } } if (Array.isArray(this.baseState.digitSets) && this.baseState.digitSets.length === 6) { for (let idx = 0; idx < DIGIT_DIVISORS.length; idx += 1) { const digit = Math.floor(number / DIGIT_DIVISORS[idx]) % 10; if (!this.baseState.digitSets[idx].includes(digit)) { return `${DIGIT_LABELS[idx]} 数位上的数字 ${digit} 已被排除(仅允许 {${this.baseState.digitSets[idx].join(',')}})`; } } } if (Array.isArray(this.baseState.ranges) && this.baseState.ranges.length > 0) { const inRange = this.baseState.ranges.some(([s, e]) => number >= s && number <= e); if (!inRange) { return '不在当前的 Range 区间范围内'; } } for (const cond of this.baseState.conditions) { if (validatePredicate(cond.predicate) && evaluatePredicate(number, cond.predicate) !== Boolean(cond.answer)) { return `不符合已知条件:${predicateDescription(cond.predicate)} (${cond.answer ? 'Yes' : 'No'})`; } } for (const item of this.assumptions) { if (item.type === 'question' && typeof item.answer === 'boolean' && validatePredicate(item.predicate)) { if (evaluatePredicate(number, item.predicate) !== item.answer) { return `不符合本地假设条件:${predicateDescription(item.predicate)} (${item.answer ? 'Yes' : 'No'})`; } } } return '已被排除'; } evaluateSplit(predicate) { let yesCount = 0; let noCount = 0; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!this.candidates[number]) continue; if (evaluatePredicate(number, predicate)) yesCount += 1; else noCount += 1; } const total = yesCount + noCount; const yesRatio = total > 0 ? yesCount / total : 0; return { yesCount, noCount, total, yesRatio, canSplit: yesCount > 0 && noCount > 0, balanceDiff: Math.abs(yesRatio - 0.5), }; } // 计算最佳二分建议与趣味高分问题列表 recommendQuestions() { const possible = []; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (this.candidates[number]) possible.push(number); } const total = possible.length; if (total <= 1) return { medianQuestion: null, alternatives: [] }; // 1. 完美中位数二分 const medianIndex = Math.floor(total / 2) - 1; const medianValue = possible[Math.max(0, medianIndex)]; const medianPred = { type: 'lte', value: medianValue }; const medianSplit = this.evaluateSplit(medianPred); const medianQuestion = { text: `Is x <= ${medianValue}?`, description: `小于等于 ${medianValue}`, predicate: medianPred, ...medianSplit, }; // 2. 扫描常见单问题模板,按接近 50%:50% 排序 const candidatesToTest = [ { text: 'Is it even?', predicate: { type: 'parity', value: 'even' } }, { text: 'Is the digit sum even?', predicate: { type: 'digitSumParity', value: 'even' } }, { text: 'Are all digits unique?', predicate: { type: 'allDigitsUnique' } }, { text: 'Are the last 4 digits all different?', predicate: { type: 'digitsAllDifferent', start: 2, length: 4 } }, { text: 'Are the first 3 digits all different?', predicate: { type: 'digitsAllDifferent', start: 0, length: 3 } }, { text: 'Are the last 3 digits all different?', predicate: { type: 'digitsAllDifferent', start: 3, length: 3 } }, { text: 'Is it divisible by 3?', predicate: { type: 'divisible', value: 3 } }, { text: 'Is it divisible by 4?', predicate: { type: 'divisible', value: 4 } }, { text: 'Is it divisible by 7?', predicate: { type: 'divisible', value: 7 } }, { text: 'Is it divisible by 11?', predicate: { type: 'divisible', value: 11 } }, ]; // 加入各数位奇偶 & 各数位中位分割 for (let idx = 0; idx < DIGIT_LABELS.length; idx += 1) { const label = DIGIT_LABELS[idx]; const divisor = DIGIT_DIVISORS[idx]; candidatesToTest.push({ text: `Is the ${label} digit even?`, predicate: { type: 'digitParity', divisor, value: 'even' }, }); for (const threshold of [2, 3, 4, 5, 6]) { candidatesToTest.push({ text: `Is the ${label} digit <= ${threshold}?`, predicate: { type: 'digitCompare', divisor, operator: 'lte', value: threshold }, }); } } const scored = []; for (const item of candidatesToTest) { const split = this.evaluateSplit(item.predicate); if (!split.canSplit) continue; scored.push({ text: item.text, description: predicateDescription(item.predicate), predicate: item.predicate, ...split, }); } scored.sort((a, b) => a.balanceDiff - b.balanceDiff); // 去重相同分割效果的同数位问题 const seenTypes = new Set(); const topAlternatives = []; for (const item of scored) { const groupKey = item.predicate.type === 'digitCompare' ? `digitCompare:${item.predicate.divisor}` : `${item.predicate.type}:${item.predicate.divisor || item.predicate.value || ''}`; if (seenTypes.has(groupKey)) continue; seenTypes.add(groupKey); topAlternatives.push(item); if (topAlternatives.length >= 5) break; } return { medianQuestion, alternatives: topAlternatives }; } // 校验玩家准备发出的内容是否符合 6 条规则 validateDraft(rawInput) { const action = parseAction(rawInput); if (action.kind === 'empty') { return { valid: false, tone: 'info', message: '请输入你要猜测的数字或 Yes/No 问题,或点击上方推荐问题。' }; } if (action.kind === 'multipart') { return { valid: false, tone: 'error', message: '❌ 违反规则 6:严禁复合问题 (Multi-part questions)!每次只能问单一条件,请去掉 and / or 或多余条件。', }; } if (action.kind === 'guess') { const num = action.value; if (!isSafeInteger(num) || num < MIN_NUMBER || num > MAX_NUMBER) { return { valid: false, tone: 'error', message: `❌ 违反规则 5:猜测数字必须在 ${MIN_NUMBER}–${MAX_NUMBER} 之间!`, }; } if (this.effectiveHp < GUESS_COST) { return { valid: false, tone: 'error', message: '❌ 违反规则 2 & 4:当前剩余 HP 为 0,无法继续猜测!', }; } if (!this.candidates[num]) { return { valid: false, tone: 'error', message: `❌ 违反规则 2(无效猜测,会被主持人忽略):${num} 不可能是正确答案!原因:${this.whyNumberExcluded(num)}。`, }; } const total = this.candidateSummary().count; const hitRate = ((1 / total) * 100).toFixed(2); return { valid: true, tone: 'success', message: `✓ 合法猜数字(消耗 1 HP,命中率 ${hitRate}%)。${num} 当前在存活候选池中!`, }; } if (action.kind === 'question') { if (this.effectiveHp < QUESTION_COST) { return { valid: false, tone: 'error', message: `❌ 违反规则 1 & 2:提问需要 ${QUESTION_COST} HP,当前预计仅剩 ${this.effectiveHp} HP!提问会被当作无效忽略,请直接猜数字!`, }; } const split = this.evaluateSplit(action.predicate); if (!split.canSplit) { const fixedAnswer = split.yesCount > 0 ? '100% 是 Yes' : '100% 是 No'; return { valid: false, tone: 'warning', message: `⚠️ 无效/浪费提问:条件「${predicateDescription(action.predicate)}」在当前候选池中答案已固定(${fixedAnswer}),无法缩小范围!`, }; } const yesPct = ((split.yesCount / split.total) * 100).toFixed(1); const noPct = ((split.noCount / split.total) * 100).toFixed(1); return { valid: true, tone: 'yes', message: `✓ 合法单问题(消耗 15 HP)| 识别为:「${predicateDescription(action.predicate)}」\n📊 分割效果:若 Yes 剩 ${split.yesCount.toLocaleString()} 个 (${yesPct}%) / 若 No 剩 ${split.noCount.toLocaleString()} 个 (${noPct}%)。`, }; } // 未被内置正则识别的自然语言问题 if (this.effectiveHp < QUESTION_COST) { return { valid: false, tone: 'error', message: `❌ 违反规则 1 & 2:当前预计仅剩 ${this.effectiveHp} HP(不足 15 HP),无法提问,请直接猜数字!`, }; } return { valid: true, tone: 'warning', message: '⚠️ 自定义问题(消耗 15 HP):内置解析器未识别该句式(主持人端可能需手动处理),请自行确认它是单个 Yes/No 问题且符合规则 6。', }; } addAssumption(item) { this.assumptions.push(item); this.rebuild(); } popAssumption() { if (!this.assumptions.length) return false; this.assumptions.pop(); this.rebuild(); return true; } clearAssumptions() { this.assumptions = []; this.rebuild(); } 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 }; } possibleNumbers(limit = CANDIDATE_CHIP_LIMIT) { const values = []; for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (this.candidates[number]) { values.push(number); if (values.length >= limit) break; } } return values; } exportState() { return { schemaVersion: SCHEMA_VERSION, baseState: this.baseState, assumptions: this.assumptions, }; } } // 扫描当前页面所有帖子,检测主持人最新楼层、玩家是否已行动(Rule 3)、是否超时 24h(Rule 5) function scanPageForumStatus(doc) { const currentUserLink = doc.querySelector('#nav_userinfo a[href*="user.php"], .username[href*="user.php"], a.username'); const currentUsername = currentUserLink ? currentUserLink.textContent.trim() : ''; const posts = []; for (const post of doc.querySelectorAll(POST_SELECTOR)) { if (!isPostContainer(post)) continue; const body = findPostBody(post); if (!body) continue; 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 postId = postIdLink ? postIdLink.textContent.trim() : (post.id || ''); const userLink = headerCell && headerCell.querySelector('a[href*="user.php"]'); const author = userLink ? userLink.textContent.trim() : ''; const timeNode = headerCell && headerCell.querySelector('.time, span[title], time'); const timeStr = timeNode ? (timeNode.getAttribute('title') || timeNode.textContent.trim()) : ''; const text = extractPostText(body); const hostParsed = parseHostSummaryText(text); const winParsed = parseWinPostText(text); posts.push({ element: post, postId, author, timeStr, text, hostParsed, winParsed, }); } let lastHostIndex = -1; let lastWinIndex = -1; for (let i = 0; i < posts.length; i += 1) { if (posts[i].hostParsed) lastHostIndex = i; if (posts[i].winParsed) lastWinIndex = i; } // 检查最后一楼距离现在是否超过 24 小时 (Rule 5) let hoursSinceLastActivity = null; if (posts.length > 0) { const lastPost = posts[posts.length - 1]; if (lastPost.timeStr) { const parsedDate = Date.parse(lastPost.timeStr); if (!Number.isNaN(parsedDate)) { hoursSinceLastActivity = (Date.now() - parsedDate) / (1000 * 3600); } else if (/\b(\d+)\s+days?\s+ago\b/i.test(lastPost.timeStr)) { const dMatch = lastPost.timeStr.match(/\b(\d+)\s+days?\s+ago\b/i); hoursSinceLastActivity = Number(dMatch[1]) * 24; } } } // 如果最后的 Win! 在最后的 Host 汇总之后,说明本局已结束等待新开局 const roundEndedWaitingNew = lastWinIndex > lastHostIndex; const pendingRepliesAfterHost = []; let userAlreadyActed = false; if (lastHostIndex !== -1 && !roundEndedWaitingNew) { const hostAuthor = posts[lastHostIndex].author; for (let i = lastHostIndex + 1; i < posts.length; i += 1) { const p = posts[i]; if (p.author && p.author !== hostAuthor) { pendingRepliesAfterHost.push(p); if (currentUsername && p.author.toLowerCase() === currentUsername.toLowerCase()) { userAlreadyActed = true; } } } } return { currentUsername, posts, lastHostPost: lastHostIndex !== -1 ? posts[lastHostIndex] : null, lastWinPost: lastWinIndex !== -1 ? posts[lastWinIndex] : null, roundEndedWaitingNew, pendingRepliesAfterHost, userAlreadyActed, hoursSinceLastActivity, }; } function installPostPlayerButtons(doc, handlers) { 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-player-toolbar]')) continue; const text = extractPostText(body); const isHostSummary = Boolean(parseHostSummaryText(text)); const isWinPost = Boolean(parseWinPostText(text)); const toolbar = doc.createElement('span'); toolbar.setAttribute('data-ggn-player-toolbar', ''); toolbar.style.cssText = 'display:inline-flex;gap:4px;margin:0 6px;vertical-align:middle;'; 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 postId = postIdLink ? postIdLink.textContent.trim() : ''; if (isHostSummary) { const syncBtn = doc.createElement('button'); syncBtn.type = 'button'; syncBtn.textContent = '📥 同步此楼状态'; syncBtn.title = '将该主持楼层的数位、范围和 HP 同步到玩家助手'; syncBtn.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 7px;cursor:pointer;background:#183153;color:#fff;border:1px solid #183153;border-radius:4px;'; syncBtn.addEventListener('click', () => { const latestText = extractPostText(findPostBody(post)); handlers.onSyncHost(latestText, postId, syncBtn); }); toolbar.appendChild(syncBtn); } if (isWinPost) { const verifyBtn = doc.createElement('button'); verifyBtn.type = 'button'; verifyBtn.textContent = '🔐 验算 PrivateBin'; verifyBtn.title = '自动解密 PrivateBin 验证主持人公布的数字是否真实'; verifyBtn.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 7px;cursor:pointer;background:#2e8a57;color:#fff;border:1px solid #2e8a57;border-radius:4px;'; verifyBtn.addEventListener('click', () => { const latestText = extractPostText(findPostBody(post)); handlers.onVerifyWin(latestText, verifyBtn); }); toolbar.appendChild(verifyBtn); } if (!isHostSummary && !isWinPost) { const assumeBtn = doc.createElement('button'); assumeBtn.type = 'button'; assumeBtn.textContent = '➕ 推演此楼发言'; assumeBtn.title = '将该玩家的未结算猜测/提问加入本地候选池推演'; assumeBtn.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 6px;cursor:pointer;'; assumeBtn.addEventListener('click', () => { const latestText = extractPostText(findPostBody(post)); handlers.onAssumePost(latestText, postId, assumeBtn); }); toolbar.appendChild(assumeBtn); } 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); } } } 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 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, INITIAL_HP, QUESTION_COST, GUESS_COST, CANDIDATE_CHIP_LIMIT, DIGIT_LABELS, DIGIT_DIVISORS, PRIVATEBIN_SITE, base58Decode, bytesToString, decipherPaste, stripPostNoise, fillQuickReply, parseQuestion, parseAction, parseDigitSetList, validatePredicate, evaluatePredicate, predicateDescription, parseHostSummaryText, parseWinPostText, PlayerEngine, scanPageForumStatus, installPostPlayerButtons, shouldRun, }; })(); if (typeof module !== 'undefined' && module.exports) { module.exports = GuessNumberPlayerCore; } if (typeof window === 'undefined' || typeof document === 'undefined') return; if (!GuessNumberPlayerCore.shouldRun(window.location.href)) return; const STORAGE_KEY = 'ggn-guess-number-player-state-v1'; const COLLAPSED_KEY = 'ggn-guess-number-player-collapsed-v1'; 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); const engine = new GuessNumberPlayerCore.PlayerEngine(savedState); let lastNotice = { text: '点击页面上主持人楼层的「📥 同步此楼状态」或点击「自动扫描本页」即可开始。', tone: 'info' }; // 注意:为避免和主持人脚本同开时遮挡,玩家面板默认放在右上偏左一点或同侧可折叠,launcher 错开高度 const host = document.createElement('div'); host.id = 'ggn-guess-number-player'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` `; const $ = (selector) => shadow.querySelector(selector); const panel = $('#panel'); const launcher = $('#launcher'); const turnBanner = $('#turn-banner'); const notice = $('#notice'); const draftInput = $('#draft-input'); const draftCheck = $('#draft-check'); const hostRawInput = $('#host-raw'); const assumeInput = $('#assume-input'); function saveState() { storageSet(STORAGE_KEY, engine.exportState()); } 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 escapeHtml(text) { return String(text).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[ch]); } async function copyText(text) { if (typeof GM_setClipboard === 'function') { await GM_setClipboard(text, 'text'); return; } if (navigator.clipboard && navigator.clipboard.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (_) {} } const field = document.createElement('textarea'); field.value = text; field.style.cssText = 'position:fixed;left:-9999px;top:0;'; document.body.appendChild(field); try { field.focus(); field.select(); document.execCommand('copy'); } finally { field.remove(); } } // 一键应用文本到草稿框 + 复制到剪贴板 + 填入论坛底部快速回复框 async function applyToQuickReply(text) { draftInput.value = text; renderDraftValidation(); const validation = engine.validateDraft(text); if (!validation.valid) { setNotice(`已拦截填入:${validation.message}`, 'error'); return; } try { await copyText(text); } catch (_) {} const filled = GuessNumberPlayerCore.fillQuickReply(document, text, true); if (filled) { setNotice(`已填入底部快速回复框并复制:「${text}」`, 'success'); } else { setNotice(`未找到底部快速回复框,但已复制到剪贴板:「${text}」`, 'warning'); } } function renderDraftValidation() { const res = engine.validateDraft(draftInput.value); draftCheck.textContent = res.message; draftCheck.className = `notice ${res.tone}`; $('#fill-quickpost').disabled = !res.valid && draftInput.value.trim().length > 0; } function renderTurnBanner() { const status = GuessNumberPlayerCore.scanPageForumStatus(document); if (status.roundEndedWaitingNew) { if (status.hoursSinceLastActivity !== null && status.hoursSinceLastActivity >= 24) { turnBanner.className = 'status-banner alert'; turnBanner.textContent = `⏰ 规则 5 提醒:距上一轮结束已超过 ${Math.floor(status.hoursSinceLastActivity)} 小时(>24h),任何人都可以发帖接盘主持新一轮!`; } else { turnBanner.className = 'status-banner wait'; turnBanner.textContent = '🏁 本轮已结束(Win!),正在等待获胜者在 24 小时内开启新一轮。'; } return; } if (status.userAlreadyActed) { turnBanner.className = 'status-banner alert'; turnBanner.textContent = `⛔ 规则 3 冷却锁:你在主持人 ${status.lastHostPost?.postId || ''} 之后已经发过言了!请等主持人回复后再行动。`; return; } if (status.lastHostPost) { const pendingCount = status.pendingRepliesAfterHost.length; turnBanner.className = 'status-banner ready'; turnBanner.textContent = pendingCount > 0 ? `🟢 当前可发言 | 主持最新在 ${status.lastHostPost.postId},其后已有 ${pendingCount} 条玩家未结算发言。` : `🟢 当前可发言 | 主持最新在 ${status.lastHostPost.postId},之后尚无玩家发言。`; return; } turnBanner.className = 'status-banner'; turnBanner.textContent = 'ℹ️ 本页未检测到主持人汇总贴,可翻到最新页或手动粘贴汇总。'; } function renderRecommendations(summary) { const recList = $('#rec-list'); const chipsContainer = $('#chips-container'); const chipsBox = $('#candidate-chips'); if (summary.count <= GuessNumberPlayerCore.CANDIDATE_CHIP_LIMIT && summary.count > 0) { chipsContainer.classList.remove('hidden'); const nums = engine.possibleNumbers(GuessNumberPlayerCore.CANDIDATE_CHIP_LIMIT); chipsBox.innerHTML = nums .map((n) => ``) .join(''); } else { chipsContainer.classList.add('hidden'); chipsBox.innerHTML = ''; } if (engine.effectiveHp < GuessNumberPlayerCore.QUESTION_COST) { recList.innerHTML = `
  • ⚠️ 剩余 HP (${engine.effectiveHp}) 不足 15,规则 2 禁止提问!请直接从候选池选号猜测(每次 1 HP)。
  • `; return; } if (summary.count <= 2) { recList.innerHTML = `
  • 🎯 候选仅剩 ${summary.count} 个,无需消耗 15 HP 提问,请直接点击下方数字猜测!
  • `; return; } const { medianQuestion, alternatives } = engine.recommendQuestions(); const items = []; if (medianQuestion && medianQuestion.canSplit) { const yPct = ((medianQuestion.yesCount / medianQuestion.total) * 100).toFixed(1); const nPct = ((medianQuestion.noCount / medianQuestion.total) * 100).toFixed(1); items.push(`
  • 🔥 ${escapeHtml(medianQuestion.text)}
    完美二分(${escapeHtml(medianQuestion.description)})| Yes: ${yPct}% / No: ${nPct}%
  • `); } for (const alt of alternatives) { const yPct = ((alt.yesCount / alt.total) * 100).toFixed(1); const nPct = ((alt.noCount / alt.total) * 100).toFixed(1); items.push(`
  • ${escapeHtml(alt.text)}
    ${escapeHtml(alt.description)} | Yes: ${yPct}% / No: ${nPct}%
  • `); } recList.innerHTML = items.length ? items.join('') : '
  • 暂无可分割候选池的问题推荐
  • '; } function render() { renderTurnBanner(); const summary = engine.candidateSummary(); const hp = engine.effectiveHp; const questionsLeft = Math.floor(hp / GuessNumberPlayerCore.QUESTION_COST); $('#hp').textContent = `${hp}/${GuessNumberPlayerCore.INITIAL_HP}`; $('#hp-metric').className = `metric ${hp >= 15 ? 'ok' : 'bad'}`; $('#candidate-count').textContent = summary.count.toLocaleString('zh-CN'); $('#question-left').textContent = `${questionsLeft} 次`; $('#q-metric').className = `metric ${questionsLeft > 0 ? '' : 'bad'}`; $('#synced-post-tag').textContent = engine.baseState.syncedPostId ? `基准楼层: ${engine.baseState.syncedPostId}` : '基准: 默认/手动'; if (shadow.activeElement !== hostRawInput && engine.baseState.rawText) { hostRawInput.value = engine.baseState.rawText; } // 渲染数位表 $('#digit-table').innerHTML = GuessNumberPlayerCore.DIGIT_LABELS.map((label, idx) => { const digits = summary.digits[idx]; return `${label}{ ${digits.join(', ')} } (${digits.length}个)`; }).join(''); renderRecommendations(summary); renderDraftValidation(); setNotice(lastNotice.text, lastNotice.tone); // 渲染推演历史 $('#undo-assume').disabled = engine.assumptions.length === 0; $('#clear-assume').disabled = engine.assumptions.length === 0; const assumeHtml = engine.assumptions.slice().reverse().map((item) => { if (item.type === 'guess') { return `
  • 排除未结算猜数 ${item.value} (-1 HP)
  • `; } return `
  • 假设「${escapeHtml(GuessNumberPlayerCore.predicateDescription(item.predicate))}」为 ${item.answer ? 'Yes' : 'No'} (-15 HP)
  • `; }); $('#assume-history').innerHTML = assumeHtml.length ? assumeHtml.join('') : '
  • 暂无本地推演假设
  • '; } // 自动扫描本页最新的主持人汇总楼层及后续未结算猜数 function autoSyncFromCurrentPage(silentIfNone = false) { const status = GuessNumberPlayerCore.scanPageForumStatus(document); if (!status.lastHostPost || !status.lastHostPost.hostParsed) { if (!silentIfNone) setNotice('当前页未找到主持人的汇总楼层,请翻到最新页或手动粘贴。', 'warning'); renderTurnBanner(); return false; } const { hostParsed, postId } = status.lastHostPost; engine.syncFromHostParsed(hostParsed, postId); // 自动把主持人楼层之后其他玩家的纯数字猜测加入预排除(防止撞号) let autoExcludedGuesses = 0; for (const reply of status.pendingRepliesAfterHost) { const act = GuessNumberPlayerCore.parseAction(reply.text); if (act.kind === 'guess' && Number.isSafeInteger(act.value)) { engine.addAssumption({ type: 'guess', value: act.value, raw: reply.text }); autoExcludedGuesses += 1; } } saveState(); setNotice( `已自动同步主持楼层 ${postId}(HP: ${hostParsed.hp})` + (autoExcludedGuesses > 0 ? `,并自动预排除了后续楼层的 ${autoExcludedGuesses} 个未结算猜数。` : '。'), 'success', ); render(); return true; } // PrivateBin 终局解密验真 async function verifyWinPost(rawText, btnElement = null) { const winInfo = GuessNumberPlayerCore.parseWinPostText(rawText); if (!winInfo || !winInfo.pasteId || !winInfo.keyBase58) { setNotice('无法从该楼层提取完整的秘密数字、口令或 PrivateBin 链接。', 'error'); return; } if (btnElement) { btnElement.disabled = true; btnElement.textContent = '解密验算中…'; } try { const url = `${GuessNumberPlayerCore.PRIVATEBIN_SITE}?${winInfo.pasteId}`; const responseText = await new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'X-Requested-With': 'JSONHttpRequest' }, timeout: 15000, onload: (res) => resolve(res.responseText), onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); } else { fetch(url, { headers: { 'X-Requested-With': 'JSONHttpRequest' } }) .then((r) => r.text()) .then(resolve, reject); } }); const fetched = JSON.parse(responseText); const keyString = GuessNumberPlayerCore.bytesToString( GuessNumberPlayerCore.base58Decode(winInfo.keyBase58), ); const plain = await GuessNumberPlayerCore.decipherPaste( keyString, winInfo.password, [fetched.ct, fetched.adata], ); let actualPaste = plain; try { const parsedJson = JSON.parse(plain); if (parsedJson && typeof parsedJson.paste === 'string') actualPaste = parsedJson.paste; } catch (_) {} const matches = String(actualPaste).trim() === String(winInfo.secret); if (matches) { setNotice(`✅ PrivateBin 密码学验真通过!解密内容为「${actualPaste}」,与主持人公布的数字 ${winInfo.secret} 完全一致。`, 'success'); if (btnElement) btnElement.textContent = '✅ 验真通过'; } else { setNotice(`❌ 验真异常!PrivateBin 解密内容为「${actualPaste}」,但公布数字为 ${winInfo.secret}。`, 'error'); if (btnElement) btnElement.textContent = '❌ 内容不符'; } } catch (err) { setNotice(`PrivateBin 验算失败:${err.message}`, 'error'); if (btnElement) btnElement.textContent = '🔐 重新验算'; } finally { if (btnElement) btnElement.disabled = false; render(); } } // 事件绑定 $('#collapse').addEventListener('click', () => setCollapsed(true)); launcher.addEventListener('click', () => setCollapsed(false)); draftInput.addEventListener('input', renderDraftValidation); draftInput.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { $('#fill-quickpost').click(); } }); $('#copy-draft').addEventListener('click', async () => { const text = draftInput.value.trim(); if (!text) return; await copyText(text); setNotice(`已复制到剪贴板:「${text}」`, 'success'); }); $('#fill-quickpost').addEventListener('click', () => { const text = draftInput.value.trim(); if (!text) { setNotice('请先输入或选择要发送的内容。', 'warning'); return; } applyToQuickReply(text); }); $('#refresh-rec').addEventListener('click', () => render()); $('#rec-list').addEventListener('click', (e) => { const btn = e.target.closest('button[data-q]'); if (!btn) return; applyToQuickReply(btn.getAttribute('data-q')); }); $('#candidate-chips').addEventListener('click', (e) => { const chip = e.target.closest('button[data-num]'); if (!chip) return; applyToQuickReply(chip.getAttribute('data-num')); }); $('#parse-host-btn').addEventListener('click', () => { const parsed = GuessNumberPlayerCore.parseHostSummaryText(hostRawInput.value); if (!parsed) { setNotice('解析失败:未识别到 Possible digits 或 HP 汇总格式。', 'error'); return; } engine.syncFromHostParsed(parsed, '手动粘贴'); saveState(); setNotice(`已从粘贴文本同步局势(HP: ${parsed.hp})。`, 'success'); render(); }); $('#auto-scan-page').addEventListener('click', () => { autoSyncFromCurrentPage(false); }); $('#reset-new-round').addEventListener('click', () => { engine.syncFromHostParsed({ digitSets: Array.from({ length: 6 }, () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), ranges: null, wrongGuesses: [], conditions: [], hp: GuessNumberPlayerCore.INITIAL_HP, privateBinUrl: '', rawText: '', }, 'New Round'); saveState(); setNotice('已重置为新回合满状态(250 HP,999,999 个候选数字)。', 'success'); render(); }); $('#assume-guess').addEventListener('click', () => { const act = GuessNumberPlayerCore.parseAction(assumeInput.value); if (act.kind !== 'guess' || !Number.isSafeInteger(act.value)) { setNotice('请输入一个纯数字以排除未结算猜数。', 'error'); return; } engine.addAssumption({ type: 'guess', value: act.value, raw: assumeInput.value.trim() }); assumeInput.value = ''; saveState(); setNotice(`已预排除猜数 ${act.value}(预扣 1 HP)。`, 'info'); render(); }); function handleAssumeQuestion(answer) { const pred = GuessNumberPlayerCore.parseQuestion(assumeInput.value); if (!pred || !GuessNumberPlayerCore.validatePredicate(pred)) { setNotice('无法解析该问题条件,请使用标准格式(如 x < 500000 或 100k digit is even)。', 'error'); return; } engine.addAssumption({ type: 'question', predicate: pred, answer, raw: assumeInput.value.trim() }); assumeInput.value = ''; saveState(); setNotice(`已添加推演假设:「${GuessNumberPlayerCore.predicateDescription(pred)}」= ${answer ? 'Yes' : 'No'}(预扣 15 HP)。`, 'info'); render(); } $('#assume-yes').addEventListener('click', () => handleAssumeQuestion(true)); $('#assume-no').addEventListener('click', () => handleAssumeQuestion(false)); $('#undo-assume').addEventListener('click', () => { if (engine.popAssumption()) { saveState(); setNotice('已撤销最近一条本地推演。', 'info'); render(); } }); $('#clear-assume').addEventListener('click', () => { engine.clearAssumptions(); saveState(); setNotice('已清空所有本地推演假设,恢复到主持人基准状态。', 'info'); render(); }); // 挂载面板与各楼层快捷按钮 document.body.appendChild(host); GuessNumberPlayerCore.installPostPlayerButtons(document, { onSyncHost: (text, postId, btn) => { setCollapsed(false); const parsed = GuessNumberPlayerCore.parseHostSummaryText(text); if (!parsed) { setNotice('该楼层无法解析出主持汇总数据。', 'error'); return; } engine.syncFromHostParsed(parsed, postId); saveState(); btn.textContent = '✓ 已同步'; setNotice(`已同步楼层 ${postId} 的主持汇总(HP: ${parsed.hp})。`, 'success'); render(); }, onVerifyWin: (text, btn) => { setCollapsed(false); verifyWinPost(text, btn); }, onAssumePost: (text, postId, btn) => { setCollapsed(false); const act = GuessNumberPlayerCore.parseAction(text); if (act.kind === 'guess' && Number.isSafeInteger(act.value)) { engine.addAssumption({ type: 'guess', value: act.value, raw: text }); saveState(); btn.textContent = '✓ 已排除此数'; setNotice(`已将 ${postId} 楼玩家的未结算猜数 ${act.value} 预排除(-1 HP)。`, 'info'); render(); return; } if (act.kind === 'question') { assumeInput.value = act.raw; setNotice(`已提取 ${postId} 楼的问题「${GuessNumberPlayerCore.predicateDescription(act.predicate)}」到下方推演框,请点击「假设为 Yes」或「假设为 No」。`, 'info'); render(); return; } assumeInput.value = text; setNotice(`已复制 ${postId} 楼正文到推演输入框。`, 'warning'); render(); }, }); setCollapsed(Boolean(storageGet(COLLAPSED_KEY, false))); // 页面加载时如果本地还没有存档,自动尝试从本页最新主持人楼层同步一次 if (!savedState) { autoSyncFromCurrentPage(true); } render(); })();