// ==UserScript== // @name GGN 猜数字玩家助手 (纯净版) // @namespace https://gazellegames.net/ // @version 1.1.0 // @description 为指定 GGN 主题提供猜数字玩家面板、主持汇总解析、最优二分推荐、规则防呆校验与一键填入回复框。 // @match https://gazellegames.net/forums.php* // @match https://www.gazellegames.net/forums.php* // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @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 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 isSafeInteger(value) { return Number.isSafeInteger(value); } 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 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((d) => Number.isInteger(d) && d >= 0 && d <= 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 aboveMin = predicate.includeValue ? number >= predicate.value : number > predicate.value; const belowMax = predicate.includeValue2 ? number <= predicate.value2 : number < predicate.value2; return aboveMin && belowMax; } 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 map = { 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 map[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 map = { 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 map[predicate.operator]; } case 'contains': return String(number).includes(predicate.value); case 'allDigitsSame': { const digits = String(number).padStart(6, '0'); return digits.split('').every((d) => d === 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 win = digits.slice(predicate.start, predicate.start + predicate.length); return new Set(win).size === win.length; } case 'digitSumParity': { const digits = String(number).padStart(6, '0'); let sum = 0; for (let i = 0; i < digits.length; i += 1) sum += Number(digits[i]); 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((p) => !/^\d$/.test(p))) return null; return [...new Set(parts.map(Number))].sort((a, b) => a - b); } 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(/\bbetween\s+(-?\d+)\s+(?:and|to)\s+(-?\d+)\b/); pair = pairFromMatch(match); if (pair) return { type: 'between', ...pair }; 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?)'; match = text.match(new RegExp(`\\b(?:the\\s+)?${placePattern}(?:\\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 map = { 'less than': 'lt', 'greater than': 'gt', 'at least': 'gte', 'at most': 'lte', 'equal to': 'eq', 'not equal to': 'neq' }; return { type: 'digitCompare', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], operator: map[match[2]], value: Number(match[3]) }; } match = text.match(new RegExp(`\\b(?:the\\s+)?${placePattern}(?:\\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]); if (setDigits && setDigits.length) return { type: 'digitInSet', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], digits: setDigits }; } match = text.match(new RegExp(`\\b(?:the\\s+)?${placePattern}(?:\\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]); if (setDigits && setDigits.length) return { type: 'digitInSet', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], digits: setDigits }; } match = text.match(new RegExp(`\\b(?:the\\s+)?${placePattern}\\s*[::]\\s*(\\d)$`)); if (match) { return { type: 'digitCompare', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], operator: 'eq', value: Number(match[2]) }; } 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 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/], ['lte', /\b(?:at\s+most|less\s+than\s+or\s+equal\s+to|no\s+more\s+than)\s*(-?\d+)\b/], ['gt', /\b(?:greater|more|larger|higher)\s+than\s*(-?\d+)\b/], ['gt', /\b(?:above|over)\s*(-?\d+)\b/], ['lt', /\b(?:less|smaller|lower|fewer)\s+than\s*(-?\d+)\b/], ['lt', /\b(?:below|under)\s*(-?\d+)\b/], ['neq', /\b(?:not\s+equal\s+to|does\s+not\s+equal)\s*(-?\d+)\b/], ]; for (const [type, pattern] of comparatorPatterns) { match = text.match(pattern); value = integerFromMatch(match); if (value !== null) return { type, value }; } 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) { return { type: 'digitCompare', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], 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) { return { type: 'digitCompare', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], operator: 'eq', value: Number(match[2]) }; } 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/); if (match) return { type: 'digitSumParity', value: match[1] }; match = text.match(new RegExp(`\\b(?:is\\s+)?(?:the\\s+)?${placePattern}(?:\\s+digit)?\\s+(?:is\\s+)?(odd|even)\\b`)); if (match) return { type: 'digitParity', divisor: placeDivisors[match[1].replace(/[- ]/g, '')], value: match[2] }; if (/\b(?:is\s+(?:it|the\s+number)\s+)?even\b/.test(text)) return { type: 'parity', value: 'even' }; if (/\b(?:is\s+(?:it|the\s+number)\s+)?odd\b/.test(text)) return { type: 'parity', value: 'odd' }; match = text.match(/\b(?:divisible|devisible|divisable|divided)\s+by\s+(-?\d+)\b/) || text.match(/\b(?:a\s+)?multiple\s+of\s+(-?\d+)\b/); 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/); value = integerFromMatch(match); if (value !== null) return { type: 'lastDigit', value }; match = text.match(/\bcontains?\s+(?:the\s+)?(?:digit|number)?\s*(\d+)\b/); 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) return allDifferentWindowPredicate('end', match[1]); match = text.match(new RegExp(`\\b(?:the\\s+)?(?:first|leading|leftmost)\\s+${windowCount}\\s+digits?\\s+${allDifferentTail}`)); if (match) return allDifferentWindowPredicate('start', match[1]); 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)) { return { type: 'allDigitsUnique' }; } if (/\b(?:are\s+)?all\s+(?:the\s+)?digits\s+(?:the\s+)?(?:same|identical)\b/.test(text)) { return { type: 'allDigitsSame' }; } return null; } // Rule 6: 严禁多合一复合问题 function detectMultiPartViolation(rawInput) { const cleaned = stripPostNoise(rawInput); if (!cleaned) return false; const withoutBraces = cleaned.replace(/\{[^}]*\}/g, '{SET}'); if ((withoutBraces.match(/[??]/g) || []).length >= 2) return true; 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)) { 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: cleanedInput }; } const uncommaed = cleanedInput.replace(/(\d),(?=\d{3}(?:\D|$))/g, '$1').trim(); const match = uncommaed.match(/^([+-]?\d+)\s*[??]?$/) || uncommaed.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: cleanedInput, value: isSafeInteger(value) ? value : Number.NaN }; } const predicate = parseQuestion(uncommaed); if (predicate && validatePredicate(predicate)) { return { kind: 'question', raw: cleanedInput, predicate }; } return { kind: 'unknown', raw: cleanedInput }; } function predicateDescription(predicate) { const digitPlaceLabels = { 100000: '十万位', 10000: '万位', 1000: '千位', 100: '百位', 10: '十位', 1: '个位' }; const comparisonLabels = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠' }; 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} 之外`, 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}`, allDigitsSame: '六个数位全部相同', allDigitsUnique: '六个数位互不相同', digitsAllDifferent: `指定 ${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); if (/New\s+Round!/i.test(text) && !/Possible\s+digits/i.test(text)) { return { digitSets: Array.from({ length: 6 }, () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), ranges: null, wrongGuesses: [], conditions: [], hp: INITIAL_HP, 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()}:`)); const match = line && line.match(/\{([^}]*)\}/); const parsed = match ? parseDigitSetList(match[1]) : null; 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 = []; for (const part of match[1].split(',').map((s) => s.trim()).filter(Boolean)) { 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' }); continue; } if (/^All\s+digits\s+same$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsSame' }, answer: true }); continue; } if (/^Not\s+all\s+digits\s+same$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsSame' }, answer: false }); continue; } if (/^All\s+digits\s+unique$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsUnique' }, answer: true }); continue; } if (/^Not\s+all\s+digits\s+unique$/i.test(line)) { conditions.push({ predicate: { type: 'allDigitsUnique' }, answer: false }); continue; } if (/^Digit\s+sum\s+even$/i.test(line)) { conditions.push({ predicate: { type: 'digitSumParity', value: 'even' }, answer: true }); continue; } if (/^Digit\s+sum\s+odd$/i.test(line)) { conditions.push({ predicate: { type: 'digitSumParity', value: 'odd' }, answer: true }); 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' }); continue; } } m = line.match(/^(.+?)\s*[::]\s*(Yes|No|True|False)$/i); if (m) { const pred = parseQuestion(m[1]); if (pred && validatePredicate(pred)) { conditions.push({ predicate: pred, answer: /^(yes|true)$/i.test(m[2]) }); continue; } } const directPred = parseQuestion(line); if (directPred && validatePredicate(directPred)) { conditions.push({ predicate: directPred, answer: true }); } } return { digitSets, ranges, wrongGuesses, conditions, hp, rawText: text }; } 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, 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, 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; 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; } } } } 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) { for (let n = Math.max(MIN_NUMBER, start); n <= Math.min(MAX_NUMBER, end); n += 1) rangeMask[n] = 1; } for (let number = MIN_NUMBER; number <= MAX_NUMBER; number += 1) { if (!rangeMask[number]) this.candidates[number] = 0; } } if (Array.isArray(this.baseState.wrongGuesses)) { for (const wrong of this.baseState.wrongGuesses) { if (wrong >= MIN_NUMBER && wrong <= MAX_NUMBER) this.candidates[wrong] = 0; } } if (Array.isArray(this.baseState.conditions)) { for (const cond of this.baseState.conditions) { if (validatePredicate(cond.predicate)) this.filterCandidates(cond.predicate, Boolean(cond.answer)); } } 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} 已被排除`; } } } 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 区间内'; } 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: [] }; const medianValue = possible[Math.max(0, Math.floor(total / 2) - 1)]; const medianPred = { type: 'lte', value: medianValue }; const medianSplit = this.evaluateSplit(medianPred); const medianQuestion = { text: `Is x <= ${medianValue}?`, description: `小于等于 ${medianValue}`, predicate: medianPred, ...medianSplit, }; 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: 'Is it divisible by 3?', predicate: { type: 'divisible', value: 3 } }, { text: 'Is it divisible by 7?', predicate: { type: 'divisible', value: 7 } }, ]; 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 [3, 4, 5]) { 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 >= 4) break; } return { medianQuestion, alternatives: topAlternatives }; } validateDraft(rawInput) { const action = parseAction(rawInput); if (action.kind === 'empty') { return { valid: false, tone: 'info', message: '请输入猜测数字或问题,或直接点击上方推荐项。' }; } if (action.kind === 'multipart') { return { valid: false, tone: 'error', message: '❌ 违反规则 6:严禁复合问题 (Multi-part questions)!每次只能问单一条件。', }; } 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; return { valid: true, tone: 'success', message: `✓ 合法猜数字(-1 HP,命中率 ${((1 / total) * 100).toFixed(2)}%)。`, }; } if (action.kind === 'question') { if (this.effectiveHp < QUESTION_COST) { return { valid: false, tone: 'error', message: `❌ 违反规则 1 & 2:提问需 15 HP,当前仅剩 ${this.effectiveHp} HP!请直接猜数字!`, }; } const split = this.evaluateSplit(action.predicate); if (!split.canSplit) { return { valid: false, tone: 'warning', message: `⚠️ 无意义提问:「${predicateDescription(action.predicate)}」答案已固定,无法缩小候选范围!`, }; } 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:HP 不足 15,只能猜数字!` }; } return { valid: true, tone: 'warning', message: '⚠️ 未被内置正则识别的自然语言问题(-15 HP),请自行确认符合单问题规则。', }; } 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 d = 0; d <= 9; d += 1) if (seen[d]) values.push(d); 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 }; } } 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 isWinPost = /Win![\s\S]*The\s+number\s+was\s+\d+/i.test(text); posts.push({ postId, author, timeStr, text, hostParsed, isWinPost }); } let lastHostIndex = -1; let lastWinIndex = -1; for (let i = 0; i < posts.length; i += 1) { if (posts[i].hostParsed) lastHostIndex = i; if (posts[i].isWinPost) lastWinIndex = i; } let hoursSinceLastActivity = null; if (posts.length > 0 && posts[posts.length - 1].timeStr) { const parsedDate = Date.parse(posts[posts.length - 1].timeStr); if (!Number.isNaN(parsedDate)) { hoursSinceLastActivity = (Date.now() - parsedDate) / (1000 * 3600); } } 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 { lastHostPost: lastHostIndex !== -1 ? posts[lastHostIndex] : null, roundEndedWaitingNew, pendingRepliesAfterHost, userAlreadyActed, hoursSinceLastActivity, }; } function installPostPlayerButtons(doc, handlers) { const view = doc.defaultView; let queued = 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 = /Win![\s\S]*The\s+number\s+was\s+\d+/i.test(text); if (isWinPost) continue; 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() : ''; const btn = doc.createElement('button'); btn.type = 'button'; if (isHostSummary) { btn.textContent = '📥 同步此楼状态'; btn.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 7px;cursor:pointer;background:#183153;color:#fff;border:1px solid #183153;border-radius:4px;'; btn.addEventListener('click', () => handlers.onSyncHost(extractPostText(findPostBody(post)), postId, btn)); } else { btn.textContent = '➕ 推演此楼发言'; btn.style.cssText = 'font:12px/1.35 sans-serif;padding:1px 6px;cursor:pointer;'; btn.addEventListener('click', () => handlers.onAssumePost(extractPostText(findPostBody(post)), postId, btn)); } toolbar.appendChild(btn); const userLink = headerCell && headerCell.querySelector('a[href*="user.php"]'); if (postIdLink) postIdLink.after(toolbar); else if (userLink) (userLink.closest('strong, b') || userLink).before(toolbar); else if (headerCell) headerCell.prepend(toolbar); else post.prepend(toolbar); } } scan(); const observer = new view.MutationObserver(() => { if (queued) return; queued = true; view.queueMicrotask(() => { queued = false; scan(); }); }); observer.observe(doc.body, { childList: true, subtree: true }); } 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, fillQuickReply, parseQuestion, parseAction, validatePredicate, predicateDescription, parseHostSummaryText, PlayerEngine, scanPageForumStatus, installPostPlayerButtons, shouldRun, }; })(); 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'; const storageGet = (k, fb) => { try { return typeof GM_getValue === 'function' ? GM_getValue(k, fb) : fb; } catch (_) { return fb; } }; const storageSet = (k, v) => { try { if (typeof GM_setValue === 'function') GM_setValue(k, v); } catch (_) {} }; const savedState = storageGet(STORAGE_KEY, null); const engine = new GuessNumberPlayerCore.PlayerEngine(savedState); let lastNotice = { text: '点击主持人楼层的「📥 同步此楼状态」或「扫描本页最新楼」即可同步局势。', tone: 'info' }; const host = document.createElement('div'); host.id = 'ggn-guess-number-player'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` `; const $ = (sel) => shadow.querySelector(sel); 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'); const saveState = () => storageSet(STORAGE_KEY, engine.exportState()); const escapeHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); 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}`; } async function applyToQuickReply(text) { draftInput.value = text; renderDraftValidation(); const validation = engine.validateDraft(text); if (!validation.valid) { setNotice(`已拦截填入:${validation.message}`, 'error'); return; } try { if (typeof GM_setClipboard === 'function') await GM_setClipboard(text, 'text'); else if (navigator.clipboard) await navigator.clipboard.writeText(text); } catch (_) {} const filled = GuessNumberPlayerCore.fillQuickReply(document, text, true); setNotice( filled ? `已填入底部快速回复框并复制:「${text}」` : `已复制到剪贴板(未找到快速回复框):「${text}」`, filled ? 'success' : '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 pCount = status.pendingRepliesAfterHost.length; turnBanner.className = 'status-banner ready'; turnBanner.textContent = pCount > 0 ? `🟢 当前可发言 | 主持最新在 ${status.lastHostPost.postId},其后有 ${pCount} 条未结算发言。` : `🟢 当前可发言 | 主持最新在 ${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'); chipsBox.innerHTML = engine.possibleNumbers(GuessNumberPlayerCore.CANDIDATE_CHIP_LIMIT) .map((n) => ``) .join(''); } else { chipsContainer.classList.add('hidden'); } if (engine.effectiveHp < GuessNumberPlayerCore.QUESTION_COST) { recList.innerHTML = `