#!/usr/bin/env node // 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。 // 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000 const ROWS = 5 const COLS = 4 const HIGH_CAP = 3 const MAX_CASCADE_WAVES = 3 const DANGER_DROP_CHANCE = 0.1 const COLUMN_GROUP_BIAS = 0.72 const FOOD_GROUPS = [ { title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' }, { title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' }, { title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' }, { title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' }, { title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' }, { title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' }, { title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' }, { title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' }, { title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' } ] const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger)) const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe)) function readNumberArg(name, fallback) { const index = process.argv.indexOf(name) if (index < 0) return fallback const value = Number(process.argv[index + 1]) return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback } const RUNS = readNumberArg('--runs', 10000) const TURNS = readNumberArg('--turns', 80) const BASE_SEED = readNumberArg('--seed', 20260722) function createRng(seed) { let state = seed >>> 0 || 1 return () => { state ^= state << 13 state ^= state >>> 17 state ^= state << 5 return (state >>> 0) / 4294967296 } } function cloneBoard(source) { return source.map(tile => tile ? { ...tile } : null) } function tile(key) { return { key } } function isAdjacent(a, b) { const ar = Math.floor(a / COLS) const ac = a % COLS const br = Math.floor(b / COLS) const bc = b % COLS return Math.abs(ar - br) + Math.abs(ac - bc) === 1 } function swap(source, a, b) { const next = cloneBoard(source) ;[next[a], next[b]] = [next[b], next[a]] return next } function shuffle(source, rng) { const result = cloneBoard(source) for (let i = result.length - 1; i > 0; i -= 1) { const j = Math.floor(rng() * (i + 1)) ;[result[i], result[j]] = [result[j], result[i]] } return result } function findMatches(source) { const groups = [] for (let row = 0; row < ROWS; row += 1) { let start = 0 while (start < COLS) { let end = start + 1 while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1 if (source[row * COLS + start] && end - start >= 3) { groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) }) } start = end } } for (let col = 0; col < COLS; col += 1) { let start = 0 while (start < ROWS) { let end = start + 1 while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1 if (source[start * COLS + col] && end - start >= 3) { groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) }) } start = end } } return groups } function enumerateMatchingMoves(source) { const moves = [] for (let i = 0; i < source.length; i += 1) { const candidates = [] if (i % COLS < COLS - 1) candidates.push(i + 1) if (i + COLS < source.length) candidates.push(i + COLS) for (const j of candidates) { const copy = swap(source, i, j) const groups = findMatches(copy) if (!groups.length) continue const indices = new Set(groups.flatMap(group => group.indices)) const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key)) moves.push({ from: i, to: j, hitsHigh, groups }) } } return moves } function safeMoves(source) { return enumerateMatchingMoves(source).filter(move => !move.hitsHigh) } function wouldCreateMatchAt(source, index, key) { const row = Math.floor(index / COLS) const col = index % COLS const keyAt = target => target === index ? key : source[target]?.key let horizontal = 1 for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1 for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1 if (horizontal >= 3) return true let vertical = 1 for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1 for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1 return vertical >= 3 } function openingKeys(state) { const [a, b, c, d] = state.activeSafeKeys const danger = FOOD_GROUPS[state.themeIndex].danger return [ a, b, c, d, b, c, d, a, c, d, a, danger, d, a, danger, b, a, b, c, d ] } function forcedPlayableKeys(state) { const [a, b, c, d] = state.activeSafeKeys const danger = FOOD_GROUPS[state.themeIndex].danger return [ d, a, danger, c, d, c, b, danger, c, a, b, a, d, d, a, a, b, d, c, b ] } function buildPlayableBoard(state, keys = openingKeys(state)) { let candidate = keys.map(tile) for (let attempt = 0; attempt < 240; attempt += 1) { if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate candidate = shuffle(candidate, state.rng) } state.metrics.guaranteedFallbacks += 1 return forcedPlayableKeys(state).map(tile) } function makeStablePlayableBoard(state, source) { const tiles = source.filter(Boolean).map(item => ({ ...item })) if (tiles.length === ROWS * COLS) { for (let attempt = 0; attempt < 300; attempt += 1) { const candidate = shuffle(tiles, state.rng) if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate } } state.metrics.stableFallbacks += 1 return buildPlayableBoard(state) } function queueSafeGroupTransition(state, keys) { const target = [...keys] const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1] if (!last || !last.every((key, index) => key === target[index])) { state.queuedSafeGroups.push(target) } startNextTransition(state) } function startNextTransition(state) { if (state.transition) return false while (state.queuedSafeGroups.length) { const target = state.queuedSafeGroups[0] const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key) if (slot < 0) { state.queuedSafeGroups.shift() continue } const from = state.activeSafeKeys[slot] const to = target[slot] state.activeSafeKeys[slot] = to state.transition = { from, to, slot, age: 0 } return true } return false } function finishTransitionIfReady(state) { let changed = false while (state.transition) { const { from, to } = state.transition const remaining = state.board.filter(item => item?.key === from).length if (remaining > 2) break if (remaining) { state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item) state.metrics.tailConversions += remaining } state.transition = null changed = true startNextTransition(state) } return changed } function prepareTransitionForReshuffle(state) { const activeSet = new Set(state.activeSafeKeys) const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0])) state.board.forEach(item => { if (item && activeSet.has(item.key)) counts[item.key] += 1 }) let imported = 0 const next = state.board.map(item => { if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item if (state.transition && item.key === state.transition.from) { imported += 1 counts[state.transition.to] += 1 return { ...item, key: state.transition.to } } if (activeSet.has(item.key)) return { ...item } const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0] counts[key] += 1 imported += 1 return { ...item, key } }) state.board = next state.metrics.reshuffleImports += imported } function randomFoodKey(state, snapshot, col) { const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) { return FOOD_GROUPS[state.themeIndex].danger } if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length] return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)] } function pickDropKey(state, snapshot, index, col) { for (let attempt = 0; attempt < 16; attempt += 1) { const key = randomFoodKey(state, snapshot, col) if (!wouldCreateMatchAt(snapshot, index, key)) return key } const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger] .find(key => !wouldCreateMatchAt(snapshot, index, key)) return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length] } function collapseAndFill(state) { const next = Array(ROWS * COLS).fill(null) for (let col = 0; col < COLS; col += 1) { const existing = [] for (let row = ROWS - 1; row >= 0; row -= 1) { const item = state.board[row * COLS + col] if (item) existing.push(item) } existing.forEach((item, offset) => { next[(ROWS - 1 - offset) * COLS + col] = item }) } for (let row = 0; row < ROWS; row += 1) { for (let col = 0; col < COLS; col += 1) { const index = row * COLS + col if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col)) } } state.board = next } function reshuffle(state) { prepareTransitionForReshuffle(state) let candidate = null for (let attempt = 0; attempt < 240; attempt += 1) { const shuffled = shuffle(state.board, state.rng) if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue candidate = shuffled break } state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state)) finishTransitionIfReady(state) state.metrics.reshuffles += 1 } function advanceTheme(state) { const previous = FOOD_GROUPS[state.themeIndex] state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length const next = FOOD_GROUPS[state.themeIndex] state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item) queueSafeGroupTransition(state, next.safe) state.metrics.themeChanges += 1 } function resolveMatches(state, initialGroups) { let groups = initialGroups let wave = 0 let camelMatch = 0 while (groups.length && wave < MAX_CASCADE_WAVES) { wave += 1 const remove = new Set(groups.flatMap(group => group.indices)) for (const group of groups) { if (group.indices.every(index => state.board[index]?.key === 'camel')) { camelMatch = Math.max(camelMatch, group.indices.length) } } remove.forEach(index => { state.board[index] = null }) collapseAndFill(state) finishTransitionIfReady(state) groups = findMatches(state.board) const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key))) if (autoHigh) { state.metrics.automaticHighAvoided += 1 state.board = makeStablePlayableBoard(state, state.board) groups = [] } else if (wave >= MAX_CASCADE_WAVES && groups.length) { state.metrics.cascadeCaps += 1 state.board = makeStablePlayableBoard(state, state.board) groups = [] } } state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave) if (camelMatch >= 3) advanceTheme(state) } function useCamelTool(state) { const danger = FOOD_GROUPS[state.themeIndex].danger const targets = state.board .map((item, index) => item?.key === danger ? index : -1) .filter(index => index >= 0) if (!targets.length) return false const index = targets[Math.floor(state.rng() * targets.length)] state.board[index] = tile('camel') state.metrics.toolUses += 1 const groups = findMatches(state.board) if (groups.length) resolveMatches(state, groups) return true } function recordFailure(state, type, detail = '') { state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1 if (state.metrics.samples.length < 20) { state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') }) } } function validateState(state, expectStable = true) { if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) { recordFailure(state, 'invalid_board_size') return false } const currentDanger = FOOD_GROUPS[state.themeIndex].danger const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key) if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(',')) if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length)) const allowed = new Set([...state.activeSafeKeys, 'camel']) if (state.transition) allowed.add(state.transition.from) const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key) if (stale.length) recordFailure(state, 'stale_food', stale.join(',')) const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key)) if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size)) if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food') if (state.transition) { const remaining = state.board.filter(item => item.key === state.transition.from).length if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`) } if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match') return true } function createMetrics() { return { runs: RUNS, turnsPerRun: TURNS, totalTurns: 0, validMoves: 0, reshuffles: 0, reshuffleImports: 0, guaranteedFallbacks: 0, stableFallbacks: 0, themeChanges: 0, toolUses: 0, tailConversions: 0, automaticHighAvoided: 0, cascadeCaps: 0, maxCascade: 0, unsafeHighMoveProbes: 0, maxTransitionAge: 0, maxQueuedThemes: 0, transitionDrainTurns: 0, unfinishedTransitions: 0, failures: {}, samples: [] } } function runTargetedTailTests(metrics) { for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) { const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length const previousGroup = FOOD_GROUPS[groupIndex] const nextGroup = FOOD_GROUPS[nextIndex] // 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物, // 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。 const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key)) if (slot < 0) continue for (const remaining of [0, 1, 2]) { const state = { run: -1, turn: remaining, rng: createRng(BASE_SEED + groupIndex * 17 + remaining), metrics, themeIndex: nextIndex, activeSafeKeys: [...nextGroup.safe], transition: { from: previousGroup.safe[slot], to: nextGroup.safe[slot], slot, age: 0 }, queuedSafeGroups: [[...nextGroup.safe]], board: [] } const fill = nextGroup.safe state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length])) for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from) finishTransitionIfReady(state) if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) { recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`) } } } } const metrics = createMetrics() runTargetedTailTests(metrics) for (let run = 0; run < RUNS; run += 1) { const state = { run, turn: 0, rng: createRng(BASE_SEED + run * 2654435761), metrics, themeIndex: run % FOOD_GROUPS.length, activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe], transition: null, queuedSafeGroups: [], board: [] } state.board = buildPlayableBoard(state) validateState(state) let toolsUsed = 0 for (let turn = 0; turn < TURNS; turn += 1) { state.turn = turn metrics.totalTurns += 1 if (state.transition) { state.transition.age += 1 metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age) } metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length) // 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。 if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) { toolsUsed += 1 finishTransitionIfReady(state) if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board) } let moves = safeMoves(state.board) if (!moves.length) { reshuffle(state) moves = safeMoves(state.board) } if (!moves.length) { recordFailure(state, 'dead_board_after_reshuffle') break } // 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。 const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh) if (unsafe) { const probe = swap(state.board, unsafe.from, unsafe.to) const direct = findMatches(probe) if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) { recordFailure(state, 'unsafe_high_probe_missed') } metrics.unsafeHighMoveProbes += 1 } const move = moves[Math.floor(state.rng() * moves.length)] state.board = swap(state.board, move.from, move.to) const groups = findMatches(state.board) if (!groups.length) { recordFailure(state, 'safe_move_without_match') break } if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) { recordFailure(state, 'safe_move_hit_high') break } resolveMatches(state, groups) metrics.validMoves += 1 // 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。 if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state) finishTransitionIfReady(state) if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board) if (!safeMoves(state.board).length) reshuffle(state) validateState(state) } // 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。 let drainTurn = 0 while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) { state.turn = TURNS + drainTurn if (state.transition) { state.transition.age += 1 metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age) } let moves = safeMoves(state.board) if (!moves.length) { reshuffle(state) moves = safeMoves(state.board) } if (!moves.length) { recordFailure(state, 'dead_board_while_draining') break } const move = moves[Math.floor(state.rng() * moves.length)] state.board = swap(state.board, move.from, move.to) resolveMatches(state, findMatches(state.board)) metrics.validMoves += 1 finishTransitionIfReady(state) if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board) if (!safeMoves(state.board).length) reshuffle(state) validateState(state) drainTurn += 1 } metrics.transitionDrainTurns += drainTurn if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1 } const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0) const result = { ok: failureCount === 0, ...metrics, failureCount } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) if (!result.ok) process.exitCode = 1