MatchMaster/tools/scan-rainbow-old-candidates.cjs
WIN-GKKD951VVMJ\Administrator bd4984c909 老彩虹最新更新
2026-09-21 18:03:23 +08:00

164 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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));