// Read-only level audit for the agreed layer_2 C candidate rules. // Run: node tools/scan-rainbow-old-candidates.cjs // Only the two reports under docs/ are written; gameplay and level data are untouched. const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const root = path.resolve(__dirname, '..'); const SECOND = new Set([2, 5, 6, 7, 8, 13, 14, 17, 21]); const THIRD = new Set([3, 4, 12]); const TYPES = new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22]); const FIELDS = new Set(('block color type position id stacking lockTime lockTime2 freezeTime floor floorTime ' + 'floorMove adhesiveTime boomTime lock questionTime addTime colorChange flowerSend flowerColor ' + 'flowerReceive vertical horizontal colorArray floorColor swichs barriersLock').split(' ')); const LABELS = { singleCell: '单格方块(1×1)', barrier: '格挡机关', obstacle: '障碍或暗灰块', simple: '单色地块', stacking: '叠加/叠加换色', adhesive: '粘合', question: '问号', floor: '地板', movingFloor: '可移动地板', flowerCover: '花瓣盖板', changingColor: '组合变色', buttonColor: '与按钮同色', }; const present = (object, key) => object[key] !== undefined && object[key] !== null; function buttonColors(level) { // Map.mapRiseFall uses the first non-vine entry at each in-bounds cell. const seen = new Set(); const colors = new Set(); for (const tile of level.risefall || []) { if (Object.hasOwn(tile, 'vine')) continue; if (!Number.isInteger(tile.x) || !Number.isInteger(tile.y) || tile.x < 0 || tile.y < 0 || tile.x >= level.map[0] || tile.y >= level.map[1]) continue; const key = `${tile.x},${tile.y}`; if (seen.has(key)) continue; seen.add(key); if (tile.unLockFall && tile.color > 0) colors.add(tile.color); } return colors; } function classify(block, buttons) { const reasons = []; // Block.initBlocks defines shape 0 as the single cell at (0, 0). if (block.block === 0) reasons.push('singleCell'); if (block.block === 23) reasons.push('barrier'); // colorArray initializes the runtime color before the candidate selection. const color = present(block, 'colorArray') && String(block.colorArray).length ? Number(String(block.colorArray)[0]) + 1 : block.color; if (block.type === 11 || color === 11) reasons.push('obstacle'); if (block.type === 22) reasons.push('simple'); if ([1, 10].includes(block.type) || present(block, 'colorChange')) reasons.push('stacking'); if ([9, 19].includes(block.type)) reasons.push('adhesive'); if (block.type === 16) reasons.push('question'); if (present(block, 'floor')) reasons.push('floor'); if (block.type === 18 || block.floorMove === true) reasons.push('movingFloor'); if (block.flowerReceive) reasons.push('flowerCover'); if (block.type === 20 || present(block, 'colorArray')) reasons.push('changingColor'); if (buttons.has(color)) reasons.push('buttonColor'); if (reasons.length) return { pool: 0, reasons, runtimeColor: color }; // Frozen/locked primary types override otherwise allowed secondary properties. if (THIRD.has(block.type)) return { pool: 3, reasons: [], runtimeColor: color }; if (SECOND.has(block.type) || block.flowerSend || present(block, 'lock') || block.horizontal || block.vertical) return { pool: 2, reasons: [], runtimeColor: color }; if (block.type !== 0) throw new Error(`Unclassified block type: ${block.type}`); // stacking/adhesiveTime on type=0 are inert leftovers: initialization uses type. return { pool: 1, reasons: [], runtimeColor: color }; } const levels = []; const hash = crypto.createHash('sha256'); for (const folder of ['Json', 'Json2']) { const directory = path.join(root, 'assets/custom', folder); const files = fs.readdirSync(directory).filter(file => /^level\d+\.json$/.test(file)) .sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); for (const filename of files) { const source = `assets/custom/${folder}/${filename}`; const raw = fs.readFileSync(path.join(root, source)); hash.update(source).update('\0').update(raw); const data = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, '')); const level = data.LEVEL_INFO[0]; const blocks = data.BLOCK_INFO[0]; const buttons = buttonColors(level); const pools = [[], [], []]; const excluded = []; for (const [index, block] of blocks.entries()) { if (!Number.isInteger(block.block) || block.block < 0 || block.block > 23 || !TYPES.has(block.type)) throw new Error(`Unknown shape/type at ${source} block ${index}`); const unknown = Object.keys(block).filter(key => !FIELDS.has(key)); if (unknown.length) throw new Error(`Unknown fields at ${source} block ${index}: ${unknown}`); const result = classify(block, buttons); const item = { index, id: block.id, type: block.type, shape: block.block, color: result.runtimeColor }; if (result.pool) pools[result.pool - 1].push(item); else excluded.push({ ...item, reasons: result.reasons, config: block }); } const selectedPool = pools.findIndex(pool => pool.length > 0) + 1; const excludedReasonCounts = {}; for (const item of excluded) for (const reason of item.reasons) { excludedReasonCounts[reason] = (excludedReasonCounts[reason] || 0) + 1; } const gates = level.spawnGates || []; levels.push({ folder, level: Number(filename.match(/\d+/)[0]), source, initialBlockEntries: blocks.length, buttonColors: [...buttons].sort((a, b) => a - b), poolCounts: pools.map(pool => pool.length), selectedPool, ...(selectedPool === 3 ? { thirdPoolCandidates: pools[2] } : {}), excludedCount: excluded.length, excludedReasonCounts, pendingSpawnEntries: gates.reduce((sum, gate) => sum + (gate.spawnQueue || []) .reduce((count, batch) => count + (batch.members || []).length, 0), 0), ...(selectedPool === 0 ? { excluded } : {}), }); } } const summary = ['Json', 'Json2'].map(folder => { const rows = levels.filter(level => level.folder === folder); return { folder, levels: rows.length, initialBlockEntries: rows.reduce((n, row) => n + row.initialBlockEntries, 0), selectedPoolCounts: [0, 1, 2, 3].map(pool => rows.filter(row => row.selectedPool === pool).length), pendingSpawnEntries: rows.reduce((n, row) => n + row.pendingSpawnEntries, 0) }; }); const report = { rules: 'layer_2 C, confirmed 2026-09-20', sourceSha256: hash.digest('hex'), scope: 'Initial BLOCK_INFO only. Spawn queues excluded. All levels scanned regardless of player eligibility.', reasonLabels: LABELS, summary, levels }; const lines = [ '# C 老彩虹开局候选池扫描', '', '规则确认日期:2026-09-20。复跑:`node tools/scan-rainbow-old-candidates.cjs`。', '', '## 最终规则', '', '- 按第一池、第二池、第三池依次兜底;仅在首个非空池内按完整的多格方块等概率抽取一个。1×1 单格方块不进入任何池。', '- 第一池:没有生效特殊机制的普通块。', '- 第二池:三种钥匙、星星、两种炸弹、水平/垂直限制、加时间、发送花瓣、开关(开闭均在此池)。', '- 第三池:冻结、两种带锁块。复合允许机制取靠后的池,转换不解除原限制。', '- 全局排除:1×1 单格方块、叠加及叠加换色、粘合(含三连)、地板、可移动地板、问号、花瓣盖板、组合变色、障碍及暗灰不可消除块、单色地块、格挡机关。', '- 继承 B 的按钮颜色排除:与开局任一按钮真实颜色相同的块,三个池均排除。存在任一禁止机制即排除整个块。', '- 只算开局已在盘面的块;尚未出场的生产队列不算候选。三个池全空则本局跳过,之后不补发。', '', '## 扫描范围与结果', '', '这是关卡配置静态扫描,没有运行 Cocos 场景,也没有验证关卡可解性或转换动画。覆盖当前仓库 custom bundle 的全部关卡配置;不按玩家连胜资格过滤。', '', '| 配置 | 关卡数 | 初始配置条目 | 第一池可用 | 需第二池兜底 | 需第三池兜底 | 三池全空 | 未出场队列条目 |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const row of summary) lines.push(`| ${row.folder} | ${row.levels} | ${row.initialBlockEntries} | ${row.selectedPoolCounts[1]} | ${row.selectedPoolCounts[2]} | ${row.selectedPoolCounts[3]} | ${row.selectedPoolCounts[0]} | ${row.pendingSpawnEntries} |`); lines.push('', '初始条目不是展开叠加上下层后的运行时块数。叠加已整体排除,所以不影响候选结果。Json2 为新手实验前 50 关;该组连胜奖励在已通关 50 关后解锁,Json2 结果作为配置覆盖参考。', '', '## 三池全空的关卡', '', '原因计数允许重叠:同一个块可以同时命中地板、花瓣盖板等多条排除规则。', '', '| 关卡 | 初始条目 | 按钮颜色 | 排除原因(条目数) | 未出场队列条目 |', '| --- | ---: | --- | --- | ---: |'); for (const row of levels.filter(row => row.selectedPool === 0)) { const reasons = Object.entries(row.excludedReasonCounts).map(([key, count]) => `${LABELS[key]} ${count}`).join(';'); lines.push(`| [${row.folder}/${row.level}](../${row.source}) | ${row.initialBlockEntries} | ${row.buttonColors.join('、') || '无'} | ${reasons} | ${row.pendingSpawnEntries} |`); } for (const pool of [2, 3]) { lines.push('', `## 需要第${pool === 2 ? '二' : '三'}池兜底的关卡`, ''); for (const folder of ['Json', 'Json2']) { const rows = levels.filter(row => row.folder === folder && row.selectedPool === pool); lines.push(`- ${folder}(共 ${rows.length} 个关卡):${rows.map(row => row.level).join('、') || '无'}。`); } } lines.push('', '## 溯源与限制', '', '- 单格形状:`Block.initBlocks` 的 `block=0` 仅占据 `(0,0)`;`RainbowRules.RAINBOW_SHAPES` 定义一致。', '- 按钮构建与颜色:`Map.mapRiseFall`;排除判定:`RainbowStreak.isButtonColor`。', '- 方块展开:`Map.blockInit`;附加机制与初始实色:`Block.init`、`Block.initColor`。', '- 只按实际生效机制识别附加属性:普通类型残留的 stacking/adhesiveTime 不会生成叠加/粘合机制,不因此排除。', '- 未知类型、形状或配置字段会令脚本失败,避免将新机制静默归入普通池。', '- [完整 JSON 明细](rainbow-old-candidate-scan.json) 保存各关三池数量、第三池兜底候选,以及空池关卡每个方块的配置和排除原因。', `- 输入文件集合 SHA-256:\`${report.sourceSha256}\`。`, ''); fs.writeFileSync(path.join(root, 'docs/rainbow-old-candidate-scan.json'), JSON.stringify(report, null, 2) + '\n'); fs.writeFileSync(path.join(root, 'docs/rainbow-old-candidate-scan.md'), lines.join('\n')); console.log(JSON.stringify({ summary, empty: levels.filter(row => !row.selectedPool).map(row => `${row.folder}/${row.level}`), thirdOnly: levels.filter(row => row.selectedPool === 3).map(row => `${row.folder}/${row.level}`), sourceSha256: report.sourceSha256 }, null, 2));