const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require(process.env.TYPESCRIPT_PATH || 'typescript'); const root = path.resolve(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); function method(name, globals = {}) { const source = ts.createSourceFile('Map.ts', read('assets/Script/Map.ts'), ts.ScriptTarget.Latest, true); const cls = source.statements.find(node => ts.isClassDeclaration(node)); const member = cls.members.find(node => node.name && node.name.getText(source) === name); assert.ok(member, name); const code = ts.transpileModule('class Subject {' + member.getText(source) + '}\nSubject;', { compilerOptions: { target: ts.ScriptTarget.ES2017 } }).outputText; return vm.runInNewContext(code, { console, ...globals }).prototype[name]; } const mod = { exports: {} }; vm.runInNewContext(ts.transpileModule(read('assets/Script/RainbowOldRules.ts'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText, { exports: mod.exports, module: mod }); const rules = mod.exports; test('runtime pools agree with all 2030 independently audited level configurations', () => { const audit = JSON.parse(read('docs/rainbow-old-candidate-scan.json')); const empty = []; for (const row of audit.levels) { const data = JSON.parse(read(row.source)); const counts = [0, 0, 0]; for (const info of data.BLOCK_INFO[0]) { const pool = rules.oldRainbowPool(info, row.buttonColors); if (pool) counts[pool - 1]++; } assert.deepEqual(counts, row.poolCounts, row.source); if (!counts.some(Boolean)) empty.push(`${row.folder}/${row.level}`); } assert.equal(audit.levels.length, 2030); assert.deepEqual(empty, ['Json/911', 'Json/1608', 'Json/1740', 'Json2/24']); }); test('whole-block selection uses the first non-empty pool with equal per-block weights', () => { const candidates = [{ pool: 2, cells: 1 }, { pool: 1, cells: 1 }, { pool: 3, cells: 5 }, { pool: 1, cells: 5 }]; assert.equal(rules.chooseOldRainbowTarget(candidates, () => 0.49), candidates[1]); assert.equal(rules.chooseOldRainbowTarget(candidates, () => 0.5), candidates[3]); assert.equal(rules.chooseOldRainbowTarget(candidates.filter(x => x.pool !== 1)), candidates[0]); assert.equal(rules.chooseOldRainbowTarget([{ pool: 0 }]), null); assert.equal(rules.oldRainbowPool({ type: 4, block: 0, color: 5, flowerSend: 3 }), 3); assert.equal(rules.oldRainbowPool({ type: 4, block: 0, color: 5, floor: 1 }), 0); assert.equal(rules.oldRainbowPool({ type: 0, block: 0, color: 5, lock: true }), 2); assert.equal(rules.oldRainbowPool({ type: 0, block: 0, color: 5 }, [5]), 0); }); function entryFixture(infos, fail = false) { let applications = 0; let readyCalls = 0; const instantiated = []; const openings = []; const errors = []; const entryEvents = []; const bottle = { active: true }; const bottleBg = { active: true }; const bottom = { getChildByName: name => ({ rainbow: bottle, rainbowBg: bottleBg })[name] }; const transition = { active: false }; const flightParent = { convertToNodeSpaceAR: point => ({ x: point.x - 10, y: point.y - 10 }) }; const blocks = infos.map(info => { const icon = { active: true, anchorX: 0, anchorY: 0.5, width: 240, height: 120, convertToWorldSpaceAR: point => ({ x: point.x + 100, y: point.y + 200 }) }; const block = { block_Info: info, type: info.type, color: info.color, oldRainbow: false, node: { addChild(child) { this.child = child; }, getChildByName() { return icon; } } }; return { active: true, getComponent() { return block; } }; }); // The actual flight controller has its own timing/background tests. This seam // lets the map receive landing and tail completion on distinct updates. class Opening { constructor(parent, source, to, onLand) { assert.equal(parent, flightParent); assert.equal(source, bottle); assert.deepEqual({ x: to.x, y: to.y }, { x: 210, y: 190 }); this.onLand = onLand; this.updates = []; bottle.active = true; openings.push(this); } update(dt, paused) { this.updates.push({ dt, paused }); if (paused) return false; if (this.landNext) { this.landNext = false; this.onLand(); } return !!this.tailFinished; } dispose() { this.disposed = true; bottle.active = false; } } const cc = { isValid: () => true, v2: (x, y) => ({ x, y }), fx: { GameConfig: { showBlockLoadError(error, retry) { errors.push({ error, retry }); } } }, instantiate(prefab) { assert.equal(prefab, 'user-prefab-25'); const child = { active: true, removeFromParent() {}, destroy() { this.destroyed = true; }, getComponent() { return { apply(block) { assert.equal(child.active, false, 'configure geometry before activation'); applications++; if (fail) throw Error('missing shape frame'); block.node.getChildByName('icon').active = false; } }; } }; instantiated.push(child); return child; } }; const globals = { cc, ...rules, RainbowOldVisual: {}, RainbowOldOpening: Opening }; const apply = method('applyOldRainbowAtStart', globals); const blocker = { removeFromParent() { this.removed = true; }, destroy() { this.destroyed = true; } }; const map = { blocks, buttonBlock: [], oldRainbowPreparing: true, oldRainbowEntryBlocker: blocker, node: { parent: { getChildByName: name => name === 'Bottom' ? bottom : null, parent: { parent: { getChildByName: name => name === 'zhuanchang' ? transition : null } } } }, magics: { parent: flightParent }, oldRainbowOpening: null, oldRainbowEntryGuidesPending: true, oldRainbowIntroPending: true, showMapEntryGuides() { entryEvents.push('guides'); }, showEnterNewModeIfNeeded() { entryEvents.push('intro'); }, finishOldRainbowOpening: method('finishOldRainbowOpening', globals), updateOldRainbowOpening: method('updateOldRainbowOpening', globals), Block_Prop: Array(25).fill(null).concat('user-prefab-25') }; map.oldRainbowEntryReady = () => { readyCalls++; apply.call(map); }; return { map, apply, blocker, instantiated, openings, errors, entryEvents, bottle, bottleBg, transition, applications: () => applications, readyCalls: () => readyCalls, setFail: value => { fail = value; } }; } test('entry converts only on landing and keeps input locked until the tail ends; revive does not recast', () => { const infos = [{ block: 18, type: 0, color: 5 }, { block: 0, type: 2, color: 2, flowerSend: 4 }]; const f = entryFixture(infos); const before = JSON.stringify(infos); f.apply.call(f.map); const opening = f.openings[0]; assert.equal(f.applications(), 0, 'flight begins before any conversion'); assert.equal(f.instantiated.length, 0, 'no rainbow overlay exists before landing'); assert.equal(f.map.oldRainbowPreparing, true); assert.equal(f.bottle.active, true); assert.equal(f.bottleBg.active, true); f.apply.call(f.map); assert.equal(f.openings.length, 1, 'repeated readiness callbacks cannot start another flight'); f.map.updateOldRainbowOpening(0.1); assert.equal(f.applications(), 0); opening.landNext = true; f.map.updateOldRainbowOpening(0.1); assert.equal(f.applications(), 1); assert.equal(f.map.blocks[0].getComponent().oldRainbow, true); assert.equal(f.map.blocks[1].getComponent().oldRainbow, false); assert.equal(JSON.stringify(infos), before); assert.equal(f.map.blocks.length, 2, 'no split and no extra block'); assert.equal(f.map.oldRainbowPreparing, true, 'landing is not the end of the opening'); assert.equal(f.blocker.destroyed, undefined); assert.equal(f.bottle.active, true); assert.equal(f.bottleBg.active, true); assert.deepEqual(f.entryEvents, [], 'guides remain deferred during the tail'); opening.tailFinished = true; f.map.updateOldRainbowOpening(0.1); assert.equal(f.map.oldRainbowPreparing, false); assert.equal(f.blocker.destroyed, true); assert.equal(opening.disposed, true); assert.equal(f.map.oldRainbowOpening, null); assert.equal(f.map.oldRainbowEntryReady, null); assert.equal(f.bottle.active, false); assert.equal(f.bottleBg.active, false); assert.deepEqual(f.entryEvents, ['guides', 'intro']); assert.equal(f.readyCalls(), 1, 'deferred map readiness runs only after tail completion'); f.apply.call(f.map); f.map.updateOldRainbowOpening(1); assert.equal(f.applications(), 1, 'the same map cannot grant a second reward'); assert.equal(f.openings.length, 1); }); test('empty opening pool consumes its chance without flight and hides the bottle and its background', () => { const f = entryFixture([{ block: 0, type: 20, color: 5 }]); f.apply.call(f.map); assert.equal(f.applications(), 0); assert.equal(f.openings.length, 0); assert.equal(f.map.oldRainbowPreparing, false); assert.equal(f.bottle.active, false); assert.equal(f.bottleBg.active, false); assert.equal(f.blocker.destroyed, true); const b = f.map.blocks[0].getComponent(); b.type = b.block_Info.type = 0; f.apply.call(f.map); assert.equal(f.applications(), 0, 'a later candidate must not grant the skipped reward'); }); test('the map forwards transition pause and cannot convert or unlock behind the transition', () => { const f = entryFixture([{ block: 0, type: 0, color: 5 }]); f.apply.call(f.map); const opening = f.openings[0]; opening.landNext = true; f.transition.active = true; f.map.updateOldRainbowOpening(2); assert.deepEqual(opening.updates, [{ dt: 2, paused: true }]); assert.equal(f.applications(), 0); assert.equal(f.map.oldRainbowPreparing, true); f.transition.active = false; f.map.updateOldRainbowOpening(0.1); assert.equal(opening.updates[1].paused, false); assert.equal(f.applications(), 1); assert.equal(f.map.oldRainbowPreparing, true); }); test('landing errors dispose the flight, retain input lock and retry without granting twice', () => { const broken = entryFixture([{ block: 0, type: 0, color: 5 }], true); broken.apply.call(broken.map); const first = broken.openings[0]; first.landNext = true; broken.map.updateOldRainbowOpening(1); assert.equal(first.disposed, true); assert.equal(broken.map.oldRainbowOpening, null); assert.equal(broken.instantiated[0].destroyed, true); assert.match(broken.errors[0].error.message, /missing shape frame/); assert.equal(broken.errors[0].retry, broken.map.oldRainbowEntryReady); assert.equal(broken.map.oldRainbowPreparing, true); assert.equal(broken.blocker.destroyed, undefined); assert.equal(broken.map.blocks[0].getComponent().oldRainbow, false); assert.equal(broken.map.blocks[0].getComponent().node.getChildByName('icon').active, true); assert.deepEqual(broken.entryEvents, []); broken.setFail(false); broken.errors[0].retry(); const second = broken.openings[1]; second.landNext = true; broken.map.updateOldRainbowOpening(1); assert.equal(broken.map.blocks[0].getComponent().oldRainbow, true); assert.equal(broken.map.oldRainbowPreparing, true); second.tailFinished = true; broken.map.updateOldRainbowOpening(0.1); assert.equal(broken.map.oldRainbowPreparing, false); assert.equal(broken.openings.length, 2); assert.equal(broken.instantiated.filter(node => !node.destroyed).length, 1); }); function gate(overrides = {}, length = false) { const wall = { wall_Info: { length: 1 }, color: 2, colorArray: [], special: 0, open: true, toggleOpen: true, ...overrides }; return { getComponent: () => wall, parent: { getChildByName: () => ({ active: length }) } }; } test('old rainbow crosses other colors while both original-color depletion and traversed gate changes run once', () => { const events = []; const block = { oldRainbow: true, color: 5, type: 0 }; const node = { getComponent: () => block }; const pass = method('passWall'); const map = { checkColor(color, prop, passed) { events.push([color, prop, passed === node]); } }; const wall = gate({ colorArray: [2, 3], changeColorWall() { events.push('door'); } }); assert.equal(pass.call(map, true, [wall], node), true); assert.deepEqual(events, [[5, false, true], 'door']); assert.equal(block.color, 5); for (const state of [{ wall_Info: null }, { wall_Info: { stickLength: 2 } }, { special: 2, open: false }, { special: 3 }, { special: 6 }, { toggleOpen: false }, { jump: true }]) { assert.equal(pass.call(map, true, [gate(state)], node), false); } assert.equal(pass.call(map, true, [gate({}, true)], node), false); block.type = 5; assert.equal(pass.call(map, true, [gate()], node), false, 'stars still require star gates'); }); test('old rainbow uses ordinary elimination events including keys, floor real color, freezing and switches', () => { for (const prop of [false, true]) { const events = []; const create = (type, info = {}, components = {}) => { const block = { type, block_Info: info }; const children = Object.entries(components).map(([name, component]) => ({ name, getComponent: () => component })); return { getComponent: () => block, children, getChildByName: name => children.find(child => child.name === name) }; }; const key = create(2); Object.assign(key.getComponent(), { color: 5, oldRainbow: true }); const nodes = [key, create(4, {}, { freeze: { reduce: (...args) => events.push(['ice', ...args]) } }), create(3, {}, { lock: { reduce: () => events.push('key') } }), create(0, { floor: 1 }, { floor: { reduce: (...args) => events.push(['floor', ...args]) } }), create(0, { lock: false }, { switchs: { change: () => events.push('switch') } })]; const map = { node: { children: nodes }, blocks: nodes.slice(), barrierBlock: [], isSpawnLockedNode: () => false, shrinkVine() {}, changeFreeze() {}, changeState() {}, changeRevolvingWall() {}, changeJumpWall() {}, changeLongAndShortWall() {}, changeStickWall() {}, changeRevolvingColorWall() {} }; method('special_Treatment', { applyRainbowElimination() { throw Error('C entered B elimination'); } }).call(map, key, true, prop); assert.deepEqual(events, [['ice', 1, prop], ['floor', 1, 5], 'switch', 'key']); assert.equal(map.blocks.includes(key), false); } }); test('countdown and initial touch state cannot start while C conversion is pending', () => { method('startUpdate').call({ oldRainbowPreparing: true, onBlockClick() { throw Error('started'); } }); method('startTimeCutDown').call({ oldRainbowPreparing: true, stopTimeCutDown() { throw Error('started'); } }); });