164 lines
11 KiB
JavaScript
164 lines
11 KiB
JavaScript
// 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));
|