// TYPESCRIPT_PATH may point to the TypeScript bundled with Cocos Creator. 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').replace(/^\uFEFF/, ''); const json = file => JSON.parse(read(file)); const plain = value => JSON.parse(JSON.stringify(value)); const v2 = (x = 0, y = 0) => ({ x, y, clone() { return v2(this.x, this.y); }, mag() { return Math.hypot(this.x, this.y); } }); function load(file, cc = {}, dependencies = {}) { const mod = { exports: {} }; vm.runInNewContext(ts.transpileModule(read(file), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText, { module: mod, exports: mod.exports, cc, require: name => { assert.ok(dependencies[name], name); return dependencies[name]; }, console, Set, Math }); return mod.exports; } function method(file, name, globals = {}) { const source = ts.createSourceFile(file, read(file), 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; const result = vm.runInNewContext(code, { cc: { v2 }, console, ...globals }); return result.prototype[name] || result[name]; } const rules = load('assets/Script/RainbowRules.ts'); const blockFile = 'assets/Script/Block.ts', mapFile = 'assets/Script/Map.ts'; const initBlocks = method(blockFile, 'initBlocks'); const pointsKey = points => points.map(p => `${p.x},${p.y}`).sort().join(';'); test('all 23 shapes match Block.initBlocks; each legal cut preserves every cell and leaves one connected prefab', () => { for (let shape = 0; shape < 23; shape++) { const block = { block_Info: { block: shape } }; initBlocks.call(block); assert.equal(pointsKey(rules.RAINBOW_SHAPES[shape]), pointsKey(block.allBlocks)); const cuts = rules.rainbowCuts(block.allBlocks); assert.equal(cuts.length > 0, shape !== 0); for (const cut of cuts) { const remainder = rules.RAINBOW_SHAPES[cut.shape].map(p => ({ x: p.x + cut.offset.x, y: p.y + cut.offset.y })); assert.equal(pointsKey([...remainder, cut.cell]), pointsKey(block.allBlocks)); assert.equal(new Set([...remainder, cut.cell].map(p => `${p.x},${p.y}`)).size, block.allBlocks.length); const connected = new Set([0]); for (let i = 0; i < remainder.length; i++) remainder.forEach((p, j) => { if ([...connected].some(k => Math.abs(p.x - remainder[k].x) + Math.abs(p.y - remainder[k].y) === 1)) connected.add(j); }); assert.equal(connected.size, remainder.length); assert.ok(fs.existsSync(path.join(root, `assets/resources/prefab/block/block${cut.shape}.prefab`))); } } assert.equal(rules.rainbowCuts(rules.RAINBOW_SHAPES[3]).some(cut => cut.cell.x === -1), false); }); test('pool priority matches agreed order, including composite properties', () => { const samples = [ [0, {}, 0], [2, {}, 1], [13, {}, 1], [14, {}, 1], [5, {}, 1], [21, {}, 1], [7, {}, 2], [8, {}, 2], [20, {}, 2], [4, {}, 3], [16, {}, 3], [3, {}, 4], [12, {}, 4], [0, { lock: false }, 4], [6, {}, 5], [17, {}, 5], [0, { floor: 1 }, 6], [1, {}, 7], [9, {}, 8], [9, { floorMove: true }, 9], [2, { floor: 1, lock: true }, 6], [1, { floorMove: true }, 9] ]; for (const [type, block_Info, expected] of samples) assert.equal(rules.rainbowPriority({ type, block_Info, teamBlocks: [{}, {}] }), expected); assert.equal(rules.rainbowPriority({ type: 0, flowerType: 1 }), 1); assert.equal(rules.rainbowPriority({ type: 0, flowerType: 2 }), 6); }); test('random selection is uniform per block first, then per legal cell', () => { const a = { priority: 0, cuts: rules.rainbowCuts(rules.RAINBOW_SHAPES[1]) }; const b = { priority: 0, cuts: rules.rainbowCuts(rules.RAINBOW_SHAPES[5]) }; const worse = { priority: 1, cuts: a.cuts }; const counts = new Map(); for (let i = 0; i < 100; i++) for (let j = 0; j < 100; j++) { const randoms = [(i + .5) / 100, (j + .5) / 100]; const result = rules.chooseRainbowTarget([a, b, worse], () => randoms.shift()); const key = [a, b, worse].indexOf(result.target) + ':' + result.target.cuts.indexOf(result.cut); counts.set(key, (counts.get(key) || 0) + 1); } assert.deepEqual([...counts.values()].sort((a, b) => a - b), [1250, 1250, 1250, 1250, 2500, 2500]); }); test('energy capacity, pause, removal gain during freeze, discard overflow, and spawn recovery', () => { const capacity = rules.RAINBOW_CAPACITY, blockEnergy = rules.RAINBOW_BLOCK_ENERGY[3]; const energy = new rules.RainbowEnergy(); assert.equal(energy.consume(), true); energy.tick(20, false); assert.equal(energy.value, 0); energy.eliminated(3); assert.equal(energy.value, blockEnergy); energy.tick(capacity - blockEnergy - 1, true); assert.equal(energy.value, capacity - 1); energy.eliminated(5); assert.equal(energy.value, capacity); assert.equal(energy.consume(), true); assert.equal(energy.consume(), false); energy.tick(5, true); energy.setAvailability(false, true); energy.tick(100, true); energy.eliminated(5); assert.equal(energy.value, 5); energy.setAvailability(true, false); energy.tick(capacity - 5, true); assert.equal(energy.value, capacity); energy.setAvailability(false, false); energy.setAvailability(true, true); assert.equal(energy.stopped, true); assert.equal(energy.consume(), false); }); test('all reachable shape textures exist, including stack colors, question shells and switches', () => { const defs = Array.from({ length: 23 }, (_, block) => ({ block, type: 1, color: 2, stacking: 7, colorArray: 34, lock: false })); defs.push({ block: 18, type: 16, color: 8 }); const paths = rules.rainbowTexturePaths(defs); for (const frame of paths) assert.ok(fs.existsSync(path.join(root, 'assets/Block', frame + '.png')), frame); for (const frame of ['2color0', '7color0', '4color0', '5color0', 'question0', '11color0', '12color0']) assert.ok(paths.includes(frame), frame); assert.equal(paths.some(frame => frame.startsWith('100color')), false); }); test('rainbow passes any color only when door state allows, without color side effects', () => { const passWall = method(mapFile, 'passWall'); const map = { checkColor() { throw Error('rainbow changed colors'); } }; const node = { getComponent: () => ({ color: 100, type: 0 }) }; function gate(overrides = {}, length = false) { const wall = { wall_Info: { color: 3, length: 1, special: 0 }, color: 3, colorArray: [3, 4], special: 0, specialBackup: 0, toggleOpen: true, open: true, changeColorWall() { throw Error('changed door color'); }, ...overrides }; return { getComponent: () => wall, parent: { getChildByName: () => ({ active: length }) } }; } assert.equal(passWall.call(map, true, [gate()], node), true); // createWall includes plain walls in each direction array; only configured doors may eliminate. for (const wall_Info of [null, undefined, { color: 3, length: 1, stickLength: 2 }]) { assert.equal(passWall.call(map, true, [gate({ wall_Info })], node), false); assert.equal(passWall.call(map, true, [gate(), gate({ wall_Info })], node), false); } // Secondary cells of a multi-cell door legitimately have length 0. assert.equal(passWall.call(map, true, [gate({ wall_Info: { color: 3, length: 0 } })], node), true); for (const state of [{ special: 2, open: false }, { special: 3 }, { specialBackup: 3 }, { special: 4 }, { special: 5 }, { special: 6 }, { specialBackup: 6 }, { toggleOpen: false }, { jump: true }]) { assert.equal(passWall.call(map, true, [gate(state)], node), false, JSON.stringify(state)); } assert.equal(passWall.call(map, true, [gate({}, true)], node), false); assert.equal(passWall.call(map, true, [gate()], { getComponent: () => ({ color: 1, type: 0 }) }), false); let changes = 0; const ordinary = gate({ color: 1, colorArray: [], changeColorWall() {} }); assert.equal(passWall.call({ checkColor() { changes++; } }, true, [ordinary], { getComponent: () => ({ color: 1, type: 0 }) }), true); assert.equal(changes, 1); }); test('rainbow passes movable monochrome blocks in both collision directions without bypassing ordinary obstacles', () => { const file = 'assets/Script/lq_collide_system/lq_collide.ts'; const disableCollider = method(file, 'disableCollider'); const onCollide = method(file, 'on_collide', { LQCollideConfig: { switch_print_log: true } }); const canPass = method(blockFile, 'canPassSameColorSimpleBlock'); let id = 0; const make = (color, type = 0, info = {}) => { const b = { color, type, block_Info: info, isTouch: true, canPassSameColorSimpleBlock: canPass }; const parent = { uuid: ++id, getComponent: () => b, x: 0, width: 120 }; return { node: { name: 'left', parent }, disableCollider, block: b }; }; for (let color = 1; color <= 10; color++) { const rainbow = make(100), simple = make(color, 22); for (const [moving, obstacle] of [[rainbow, simple], [simple, rainbow]]) { assert.equal(disableCollider.call(moving, obstacle), true); onCollide.call(moving, obstacle); assert.equal(moving.block.checkCollision, undefined, 'the overlapping tile must not stop movement'); } assert.equal(disableCollider.call(make(color), simple), true, 'ordinary same-color access'); assert.equal(disableCollider.call(make(color % 10 + 1), simple), false, 'ordinary wrong color still collides'); const floorGroup = make(color, 0, { floorMove: true }); assert.equal(disableCollider.call(floorGroup, simple), false, 'moving floor restrictions remain'); const flower = make(color); flower.block.flowerType = 2; assert.equal(disableCollider.call(flower, simple), false, 'flower cover restrictions remain'); assert.equal(disableCollider.call(rainbow, make(color)), false, 'rainbow cannot pass through ordinary blocks'); assert.equal(disableCollider.call(simple, make(color, 22)), false, 'two monochrome tiles still collide'); } const rainbow = make(100); const wall = { node: { name: 'wall', parent: { uuid: ++id, getComponent: () => null } } }; assert.equal(disableCollider.call(rainbow, wall), false); onCollide.call(rainbow, wall); assert.equal(rainbow.block.checkCollision, true); assert.equal(rainbow.block.moveLeft, false); }); test('rainbow passes fixed monochrome rise tiles through their real-map whitelist', () => { const file = 'assets/Script/lq_collide_system/lq_collide.ts'; const map = { riseFallBlock: [] }; const disableCollider = method(file, 'disableCollider', { MapConroler: { _instance: map } }); const onCollide = method(file, 'on_collide', { LQCollideConfig: { switch_print_log: true } }); const onEnter = method(file, 'on_enter', { cc: { fx: { AudioManager: { _instance: { playEffect() { throw Error('allowed rainbow passage played blocked sound'); } } } } } }); const b = { color: 100, type: 0, block_Info: {}, isTouch: true }; const rainbow = { node: { name: 'left', parent: { uuid: 'rainbow', getComponent: () => b } }, disableCollider }; for (let color = 1; color <= 10; color++) { const tile = { uuid: 'rise' + color, getComponent: () => null, getChildByName: () => ({ getChildByName: () => ({ getComponent: () => ({ string: String(color) }) }) }) }; map.riseFallBlock = [tile]; const fixed = { node: { name: 'rise', parent: tile } }; assert.equal(disableCollider.call(rainbow, fixed), true); onEnter.call(rainbow, fixed); onCollide.call(rainbow, fixed); assert.equal(b.checkCollision, undefined); // Vines and extendable poles reuse rise; they are not color-pass tiles. map.riseFallBlock = []; assert.equal(disableCollider.call(rainbow, fixed), false); } }); let uuid = 0; class Node { constructor(name = '') { this.name = name; this.uuid = String(++uuid); this.children = []; this.components = {}; this.active = true; this.x = 0; this.y = 0; this.width = 120; this.height = 120; this.anchorX = 1; this.anchorY = 0; this.scaleX = this.scaleY = 1; } get position() { return v2(this.x, this.y); } getPosition() { return this.position; } get parent() { return this._parent; } set parent(node) { if (this._parent === node) return; if (this._parent) this._parent.children = this._parent.children.filter(child => child !== this); this._parent = node; if (node && !node.children.includes(this)) node.children.push(this); } setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; } getContentSize() { return { width: this.width, height: this.height }; } setContentSize(size, height) { Object.assign(this, typeof size === 'number' ? { width: size, height } : size); } getAnchorPoint() { return v2(this.anchorX, this.anchorY); } setAnchorPoint(p) { this.anchorX = p.x; this.anchorY = p.y; } getChildByName(name) { return this.children.find(n => n.name === name); } getComponent(name) { return this.components[typeof name === 'string' ? name : name.name] || null; } addComponent(name) { const c = new name(); c.node = this; this.components[name.name] = c; return c; } addChild(child) { child.parent = this; if (!this.children.includes(child)) this.children.push(child); } setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this); } removeFromParent() { if (this.parent) this.parent.children = this.parent.children.filter(n => n !== this); this.parent = null; } destroy() { this.destroyed = true; } getNumberOfRunningActions() { return 0; } convertToNodeSpaceAR(p) { return p.clone(); } convertToWorldSpaceAR(p) { return p.clone(); } } class Sprite { constructor() { this.fillRange = 1; } } Sprite.SizeMode = { CUSTOM: 0 }; class Texture2D { initWithData(pixels, format, width, height) { Object.assign(this, { pixels, format, width, height }); } destroy() { this.destroyed = true; } } Texture2D.PixelFormat = { RGBA8888: 6 }; class SpriteFrame { constructor(texture) { this.texture = texture; } destroy() { this.destroyed = true; } } class ParticleSystem { constructor() { this.resets = 0; this.particleCount = 0; } resetSystem() { this.resets++; this.active = true; } stopSystem() { this.active = false; } } ParticleSystem.PositionType = { FREE: 0 }; ParticleSystem.EmitterMode = { GRAVITY: 0 }; class PolygonCollider {} const engine = { Node, Sprite, Texture2D, SpriteFrame, ParticleSystem, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b, a = 255) => ({r,g,b,a}), Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771 }, isValid: node => !!node && !node.destroyed, game: { on() {}, off() {}, EVENT_HIDE: 'hide', EVENT_SHOW: 'show' }, find: () => new Node('Canvas') }; const trailModule = load('assets/Script/RainbowTrail.ts', engine); function block(shape = 1, type = 0, info = {}) { const node = new Node('block' + shape); const b = { node, block_Info: { block: shape, type, color: 2, ...info }, type, color: 2, posX: 5, posY: 5, teamBlocks: [], adhesiveNode: [], allBlocks: [], isTouch: false, blockId: Number(node.uuid), initBlocks, ice_SpriteFrame: { getSpriteFrame: name => name }, flower_SpriteFrame: { getSpriteFrame: name => name }, setHitPosition() {}, setMapBlock() {}, cmupdate() {}, getStackingPos: () => v2(-21,22) }; initBlocks.call(b); node.components.Block = b; return b; } function controllerFixture(blocks = [block(0), block()], applyCut = () => {}) { const applied = []; const Controller = load('assets/Script/RainbowStreak.ts', engine, { './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => { applied.push(args); return applyCut(...args); } }, './RainbowTrail': trailModule, './RainbowBottleWater': { default: class RainbowBottleWater {} } }).default; const map = { blocks: blocks.map(b => b.node), gameStart: true, scheduleCallback() {}, timeNumber: 100, hasPendingSpawnGateWork: () => false, isPauseOpen: () => false, iceTrue: () => false, magicMask: { active: false } }; map.node = new Node('Map'); map.node.children = map.blocks; const bottle = new Node('bottle'); bottle.addComponent(Sprite); const controller = new Controller(map, bottle); return { controller, map, bottle, applied, blocks }; } function startCast(f) { f.controller.first = false; f.controller.update(0); } test('elimination credits each block once by its current cell count, including cut remainders and rainbow cells', () => { for (const [shape, seconds] of [[0, 2], [1, 2.5], [3, 2.8], [5, 3], [18, 3.2]]) { const b = block(shape), f = controllerFixture([b, block()]); f.controller.energy.consume(); f.controller.eliminated(b.node); f.controller.eliminated(b.node); assert.equal(f.controller.energy.value, seconds, 'shape ' + shape); } const f = cutFixture(18), c = controllerFixture([f.b]); c.controller.energy.consume(); const rainbow = f.cut.applyRainbowCut(f.map, f.b, rules.rainbowCuts(f.b.allBlocks)[0]); assert.equal(c.controller.energy.value, 0, 'cutting itself grants no energy'); assert.equal(f.b.allBlocks.length, 4); c.controller.eliminated(f.b.node); assert.equal(c.controller.energy.value, 3, 'remainder uses four cells, not the original five'); c.controller.eliminated(rainbow); assert.equal(c.controller.energy.value, 5, 'rainbow adds the one-cell reward'); }); function inputMethods(f, blocks = f.blocks) { const BlockType = { 普通块: 0, 叠加块下: 1, 钥匙块: 2, 上锁块: 3, 冻结块: 4, 粘合块: 9, 第二上锁块: 12, 单色地块: 22 }; const globals = { MapConroler: { _instance: f.map }, BlockType, cc: { v2, Intersection: { pointInPolygon: () => true }, fx: { AudioManager: { _instance: { playEffect() {} } } } }, LQCollideSystem: { update_logic() {} } }; Object.assign(f.map, { rainbowStreak: f.controller, total_steps_count: 0, startUpdate() {}, changeRiseFall() {}, downDoor() {}, removeOneBlock() {} }); for (const b of blocks) { b.node.parent = f.map.node; Object.assign(b, { collider: { world: { points: [] } }, hit: b.hit || new Node('hit'), maxSpeed: 300, setVibrate() {}, resetStartPos: () => false, blockFall() {}, touchDelta: v2() }); } return Object.fromEntries(['touchStart', 'touchMove', 'touchEnd', 'update'].map(name => [name, method(blockFile, name, globals)])); } test('defeat greys the bottle, blocks both energy sources and full casts, and revival restores saved energy', () => { for (const energy of [12, rules.RAINBOW_CAPACITY]) { const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = energy; f.map.gameOver = true; f.controller.update(60); f.controller.eliminated(block(3).node); assert.equal(f.controller.energy.value, energy); assert.equal(f.controller.casting, false); assert.deepEqual(f.bottle.color, engine.color(110, 110, 110)); assert.equal(f.controller.energy.stopped, false, 'defeat must not permanently disable the bottle'); f.map.gameOver = false; f.controller.update(0); assert.equal(f.bottle.color, engine.Color.WHITE); assert.equal(f.controller.casting, energy === rules.RAINBOW_CAPACITY); if (energy < rules.RAINBOW_CAPACITY) { assert.equal(f.bottle.getComponent(Sprite).fillRange, energy / rules.RAINBOW_CAPACITY); f.controller.update(1); assert.equal(f.controller.energy.value, energy + 1); } } const f = controllerFixture(); startCast(f); const target = f.controller.selection.block; f.map.gameOver = true; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0, 'a flight cannot finish cutting after defeat'); f.map.gameOver = false; f.controller.update(1); assert.equal(f.controller.energy.value, 1); assert.equal(f.controller.casting, false); }); test('button colors protect all matching blocks and either stack layer, and color changes refresh the pool', () => { const a = block(), b = block(), stack = block(1, 1), upper = block(1, 10); a.color = 2; b.color = 3; stack.color = 4; upper.color = 2; stack.block_Info.node = upper.node; const f = controllerFixture([a, b, stack]); const button = new Node('button'); button.components.MapBlock = { color: 2, nowColor: 2 }; f.map.buttonBlock = [button]; assert.deepEqual(Array.from(f.controller.candidates(), c => c.block), [b]); upper.color = 5; assert.deepEqual(Array.from(f.controller.candidates(), c => c.block), [b, stack]); b.color = stack.color = 2; f.controller.first = false; f.controller.energy.value = 15; f.controller.update(10); assert.equal(f.controller.energy.unavailable, true); assert.equal(f.controller.energy.stopped, false, 'protected colors may change later'); assert.equal(f.controller.energy.value, 15); b.color = 3; f.controller.update(1); assert.equal(f.controller.energy.unavailable, false); assert.equal(f.controller.energy.value, 16); f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(0); assert.equal(f.controller.selection.block, b); b.color = 2; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0, 'a target that becomes a button color in flight must not be cut'); }); test('vine initialization uses a separate collision marker and still blocks ordinary and rainbow blocks', () => { const tile = new Node('tile'); tile.components.MapBlock = { block_Id: '' }; const rise = new Node('rise'), fall = new Node('risefall'), label = new Node('color'); label.components.Label = { string: '' }; fall.addChild(label); tile.addChild(fall); tile.addChild(rise); const cc = { ...engine, Label: 'Label' }; const map = { mapWidth: 1, mapHeight: 1, mapBlocksWall: [[tile]], riseFallBlock: [], isVineInfo: method(mapFile, 'isVineInfo'), getVineIconName: () => 'single', vinePrefabWarningShown: true }; method(mapFile, 'initVine', { cc }).call(map, [{ x: 0, y: 0, vine: 1 }]); assert.equal(label.components.Label.string, '101'); assert.equal(tile.components.MapBlock.block_Id, 'Vine'); assert.equal(map.vineBlock.length, 1); const file = 'assets/Script/lq_collide_system/lq_collide.ts'; const disableCollider = method(file, 'disableCollider', { MapConroler: { _instance: map } }); const onCollide = method(file, 'on_collide', { cc, LQCollideConfig: { switch_print_log: true } }); for (const color of [1, 2, 10, 100]) { const b = block(); b.color = color; b.isTouch = true; b.moveLeft = true; const side = new Node('left'); b.node.addChild(side); const moving = { node: side, disableCollider }; onCollide.call(moving, { node: rise }); assert.equal(b.moveLeft, false, 'vine must block color ' + color); assert.equal(b.checkCollision, true); } }); test('rainbow passage hints lower every usable color and restore fixed tiles on release', () => { const changeRiseFall = method(mapFile, 'changeRiseFall', { cc: { Label: 'Label' } }); const tiles = [1, 2, 3].map(color => { const tile = new Node('tile'), fall = new Node('risefall'), up = new Node('riseup'), label = new Node('color'); label.components.Label = { string: String(color) }; fall.addChild(label); tile.addChild(fall); tile.addChild(up); fall.active = false; return tile; }); const map = { riseFallBlock: tiles }; changeRiseFall.call(map, 100, true); assert.ok(tiles.every(tile => tile.getChildByName('risefall').active && !tile.getChildByName('riseup').active)); changeRiseFall.call(map, 100, false); assert.ok(tiles.every(tile => !tile.getChildByName('risefall').active && tile.getChildByName('riseup').active)); changeRiseFall.call(map, 2, true); assert.deepEqual(tiles.map(tile => tile.getChildByName('risefall').active), [false, true, false]); const downDoor = method(mapFile, 'downDoor'), passWall = method(mapFile, 'passWall'); function gate(overrides = {}, length = false) { const wall = { color: 3, special: 0, specialBackup: 0, colorArray: [], wall_Info: { color: 3, length: 1 }, downCount: 0, downDoor() { this.downCount++; }, ...overrides }; const node = { getComponent: () => wall }; const parent = { getChildByName: name => name === 'wall' ? node : { active: length } }; node.parent = parent; return { wall, node, parent }; } const gates = [gate({ color: 1 }), gate({ color: 2 }), gate(), gate({ special: 1 }), gate({ wall_Info: null }), gate({ wall_Info: { stickLength: 2 } }), gate({}, true), gate({ special: 2, open: false }), gate({ special: 2, open: true }), ...[3,4,5,6].map(special => gate({ special })), gate({ specialBackup: 3 }), gate({ specialBackup: 6 }), gate({ toggleOpen: false }), gate({ jump: true })]; downDoor.call({ wallArray: gates.map(g => g.parent) }, 100, 0); const rainbow = { getComponent: () => ({ color: 100, type: 0 }) }; for (const g of gates) assert.equal(g.wall.downCount > 0, passWall.call({}, true, [g.node], rainbow)); const ordinary = [gate({ color: 1 }), gate({ color: 2 })]; downDoor.call({ wallArray: ordinary.map(g => g.parent) }, 2, 0); assert.deepEqual(ordinary.map(g => g.wall.downCount), [0, 1]); }); test('bottle wave attaches at runtime and keeps current energy while its material loads', () => { assert.ok(controllerFixture().bottle.getComponent('RainbowBottleWater')); const texture = { width: 225, height: 169, packable: true }; const uniforms = {}; const material = { setProperty: (name, value) => { uniforms[name] = plain(value); }, define: (name, value) => { uniforms[name] = value; } }; const sprite = { type: 3, fillRange: .2, spriteFrame: { getTexture: () => texture, getRect: () => ({ x: 0, y: 0, width: 225, height: 169 }), isRotated: () => false }, setMaterial(index, value) { assert.equal(index, 0); this.material = value; }, getMaterial() { return this.material; } }; let finish; const cc = { Sprite: { Type: { SIMPLE: 0 } }, Material: class Material {}, isValid: value => !value.destroyed, sys: { glExtension: () => true }, resources: { load(url, type, callback) { assert.equal(url, 'shader/rainbow_bottle'); assert.equal(type, cc.Material); finish = callback; } } }; const file = 'assets/Script/RainbowBottleWater.ts'; const water = { getComponent: () => sprite, node: { height: 169 }, elapsed: 0, waveHeight: 4, waveSpeed: 1, lateUpdate: method(file, 'lateUpdate') }; const onLoad = method(file, 'onLoad', { cc }); onLoad.call(water); assert.equal(texture.packable, false); assert.equal(sprite.type, 3, 'retain normal energy rendering until material is ready'); water.lateUpdate(1); sprite.fillRange = .5; finish(null, material); assert.equal(sprite.type, 0); assert.equal(uniforms.CC_SUPPORT_standard_derivatives, true); assert.deepEqual(uniforms.uvRect, [0, 0, 1, 1]); assert.equal(uniforms.water[0], .5, 'use latest energy, not value from load start'); water.lateUpdate(1.2); assert.equal(uniforms.water[1], 1.2, 'wave advances while energy stays still'); assert.equal(sprite.fillRange, .5); sprite.type = 3; onLoad.call(water); water.destroyed = true; finish(null, material); assert.equal(sprite.type, 3, 'ignore material completion after scene exit'); }); test('the first click casts immediately; later casts wait for release and selected blocks remain movable', () => { const f = controllerFixture(); const [first, target] = f.blocks; const input = inputMethods(f); const press = { getLocation: () => v2() }; assert.equal(input.touchStart.call(first, press), true); assert.equal(first.isTouch, true); assert.equal(f.controller.casting, true, 'first cast must start inside touchStart before release or update'); assert.equal(f.controller.energy.value, 0); input.touchEnd.call(first, press); f.controller.update(0); assert.equal(f.controller.selection.block, target); assert.equal(f.controller.energy.value, 0); assert.equal(input.touchStart.call(target, press), true); input.touchMove.call(target, { getLocation: () => v2(60, 30), getDelta: () => v2() }); input.update.call(target, 1 / 60); assert.ok(target.node.x > 0 && target.node.y > 0, 'normal drag updates the selected block position'); assert.equal(target.posX, f.controller.selection.x, 'grid coordinates have not caught up with dragging'); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0); assert.equal(f.controller.casting, false); assert.equal(target.block_Info.block, 1); f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(1); assert.equal(f.controller.casting, false, 'a later full bottle still waits while held'); input.touchEnd.call(target, press); f.controller.update(0); assert.equal(f.controller.casting, true); assert.equal(f.map.pause, undefined); }); test('first cast avoids the held block; subsequent full casts wait for release, settling and other held blocks', () => { const target = block(), f = controllerFixture([target]); const input = inputMethods(f), press = { getLocation: () => v2() }; input.touchStart.call(target, press); assert.equal(f.controller.energy.value, 0, 'only the held block remains, so the first cast is spent without a target'); f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(10); assert.equal(f.controller.casting, false); input.touchEnd.call(target, press); target.node.getNumberOfRunningActions = () => 1; f.controller.update(0); assert.equal(f.controller.casting, false); target.node.getNumberOfRunningActions = () => 0; // Movable obstacles can be outside the victory/candidate array, but their touch still delays a cast. const obstacle = block(0); obstacle.color = 11; obstacle.isTouch = true; f.map.node.children = [...f.map.blocks, obstacle.node]; f.controller.update(0); assert.equal(f.controller.casting, false); obstacle.isTouch = false; f.controller.update(0); assert.equal(f.controller.selection.block, target); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 1); }); test('a valid target press permanently misses even after a same-frame release or returning to the original position', () => { for (const moved of [false, true]) { const f = controllerFixture(), target = f.blocks[1]; const input = inputMethods(f), press = { getLocation: () => v2() }; startCast(f); const origin = plain(target.node.position); assert.equal(input.touchStart.call(target, press), true); assert.equal(f.controller.selection.missed, true); if (moved) { target.node.x += 120; target.node.setPosition(origin); } input.touchEnd.call(target, press); assert.equal(target.isTouch, false); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0, 'release or returning cannot restore a spent cast'); assert.equal(f.controller.energy.value, rules.RAINBOW_CAST_SECONDS); assert.equal(target.block_Info.block, 1); f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(0); assert.equal(f.controller.selection.missed, false, 'only the next cast starts with a clean flag'); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 1); } }); test('first-click exclusion and permanent misses cover forward, reverse, adhesive, stacked and chained links', () => { for (const relation of ['team', 'reverse', 'adhesive', 'stack', 'chain']) { const target = block(), member = block(0), bridge = block(0); if (relation === 'team') target.teamBlocks = [target.node, member.node]; if (relation === 'reverse') member.teamBlocks = [target.node, member.node]; if (relation === 'adhesive') { target.type = 9; target.block_Info.node = member.node; } if (relation === 'stack') { target.type = 1; member.type = 10; target.block_Info.node = member.node; } if (relation === 'chain') { target.teamBlocks = [bridge.node]; member.type = 9; member.block_Info.node = bridge.node; } const f = controllerFixture([target, member, bridge]); member.isTouch = true; f.controller.onPress(member); assert.equal(f.controller.casting, false, relation + ': first gesture excludes the whole group'); assert.equal(f.controller.energy.value, 0); member.isTouch = false; f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(0); assert.equal(f.controller.selection.block, target); member.isTouch = true; f.controller.onPress(member); member.isTouch = false; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0, relation + ': indirect press is remembered after release'); } }); test('unrelated interactions do not cancel a flight; group relinking or settling invalidates it permanently', () => { for (const change of ['unrelated_press', 'unrelated_animation', 'links', 'settling']) { const target = block(), member = block(0), other = block(0); target.teamBlocks = [member.node]; const f = controllerFixture([other, target, member]); startCast(f); if (change === 'unrelated_press') { other.isTouch = true; f.controller.onPress(other); other.isTouch = false; } if (change === 'unrelated_animation') other.node.getNumberOfRunningActions = () => 1; if (change === 'links') { target.teamBlocks = []; member.teamBlocks = [target.node]; } if (change === 'settling') member.node.getNumberOfRunningActions = () => 1; f.controller.update(.1); const missed = change === 'links' || change === 'settling'; assert.equal(f.controller.selection.missed, missed); target.teamBlocks = [member.node]; member.teamBlocks = []; member.node.getNumberOfRunningActions = () => 0; f.controller.update(rules.RAINBOW_CAST_SECONDS - .1); assert.equal(f.applied.length, missed ? 0 : 1, change); } }); test('flight keeps its original destination; positional fallback still protects non-input movement', () => { for (const state of ['dragging', 'released_elsewhere', 'returned', 'replaced']) { const f = controllerFixture(), target = f.blocks[1]; const parent = new Node('effects'); f.map.magics = new Node('magics'); parent.addChild(f.map.magics); f.map.mapBlocksWall = Array.from({ length: 10 }, (_, x) => Array.from({ length: 10 }, (_, y) => { const cell = new Node('cell'); cell.setPosition(x * 120, y * 120); parent.addChild(cell); return cell; })); startCast(f); const destination = plain(f.controller.to), trail = f.controller.flight; target.node.x += 120; if (state === 'dragging') target.isTouch = true; if (state === 'released_elsewhere') target.posX++; if (state === 'returned') target.node.x -= 120; if (state === 'replaced') f.map.blocks = [f.blocks[0].node, block().node]; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.deepEqual(plain(trail.node.position), destination, state); assert.equal(f.applied.length, state === 'returned' ? 1 : 0, state); assert.equal(f.controller.casting, false); assert.equal(f.controller.energy.value, rules.RAINBOW_CAST_SECONDS, 'misses do not refund the cast'); } }); test('a shifted three-cell bar cannot substitute its middle cell for the originally selected end cell', () => { for (const moved of [false, true]) for (const released of [false, true]) { const f = cutFixture(3); const left = rules.rainbowCuts(f.b.allBlocks).find(cut => cut.cell.x === -2); assert.ok(left); const c = controllerFixture([block(0), f.b], (_map, target, cut) => f.cut.applyRainbowCut(f.map, target, cut)); c.controller.candidates = () => [{ block: f.b, priority: 0, cuts: [left] }]; startCast(c); const originalCellX = f.b.node.x + left.cell.x * 120; if (moved) { f.b.node.x -= 120; if (released) f.b.posX--; else f.b.isTouch = true; assert.equal(f.b.node.x - 120, originalCellX, 'the middle cell now covers the old destination'); } c.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(c.applied.length, moved ? 0 : 1); if (moved) { assert.equal(f.b.block_Info.block, 3); assert.equal(f.b.allBlocks.length, 3); assert.equal(f.map.blocks.length, 1, 'a miss must create no rainbow or extra fragments'); } else { assert.equal(f.b.allBlocks.length, 2); assert.equal(f.map.blocks.length, 2, 'the unchanged end cell produces one rainbow and one remainder'); } } }); test('linked floor drivers remain movable during flight; reverse-linked movement also makes the cast miss', () => { const target = block(), driver = block(0); driver.teamBlocks = [target.node, driver.node]; driver.moveFloorPd = true; driver.floorOffset = [v2(), v2()]; const f = controllerFixture([target, driver]); const input = inputMethods(f); startCast(f); assert.equal(f.controller.selection.block, target); assert.equal(input.touchStart.call(driver, { getLocation: () => v2() }), true); driver.node.x = 20; input.update.call(driver, 1 / 60); assert.equal(target.node.x, 20, 'the original floor link still moves the target'); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0); assert.equal(driver.moveFloorPd, true); assert.deepEqual(driver.teamBlocks, [target.node, driver.node]); }); test('rainbow trail pauses without restarting, fades after arrival even when bottle stops, and releases textures', () => { const f = controllerFixture(); const parent = new Node('effects'); f.map.magics = new Node('magics'); parent.addChild(f.map.magics); f.map.mapBlocksWall = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => { const cell = new Node('cell'); parent.addChild(cell); return cell; })); startCast(f); const trail = f.controller.flight; const particles = trail.node.children.map(node => node.getComponent(ParticleSystem)).filter(Boolean); const frames = particles.map(p => p.spriteFrame); particles.forEach(p => p.particleCount = 8); const elapsed = rules.RAINBOW_CAST_SECONDS * .4; f.controller.update(elapsed); const position = plain(trail.node.position); f.map.pause = true; f.controller.update(10); assert.equal(f.applied.length, 0); assert.deepEqual(plain(trail.node.position), position); assert.equal(trail.node.active, false); f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS - elapsed); assert.equal(f.applied.length, 1); assert.equal(trail.node.active, true); assert.equal(trail.node.destroyed, undefined); assert.ok(particles.every(p => !p.active && p.resets === 1)); assert.equal(trail.node.getChildByName('rainbowHead').active, false); f.controller.energy.stopped = true; f.controller.hide(); f.controller.update(10); assert.equal(trail.node.destroyed, undefined); f.controller.show(); f.controller.update(1); assert.equal(trail.node.destroyed, undefined); particles.forEach(p => p.particleCount = 0); f.controller.update(.1); assert.equal(trail.node.destroyed, true); assert.ok(frames.every(frame => frame.destroyed && frame.texture.destroyed)); assert.equal(f.controller.fadingFlights.length, 0); }); test('scene exit disposes an in-flight rainbow effect without applying a cut', () => { const f = controllerFixture(); startCast(f); const trail = f.controller.flight = new trailModule.default(new Node('effects'), rules.RAINBOW_CAST_SECONDS); const textures = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean).map(p => p.spriteFrame.texture); f.controller.dispose(); assert.equal(f.applied.length, 0); assert.equal(trail.node.destroyed, true); assert.ok(textures.every(texture => texture.destroyed)); assert.equal(f.controller.casting, false); }); test('a target eliminated during flight consumes the cast without changing another block', () => { const f = controllerFixture(); startCast(f); const target = f.controller.selection.block; f.map.blocks = f.map.blocks.filter(node => node !== target.node); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length, 0); assert.equal(f.controller.casting, false); assert.equal(f.controller.energy.value, 0, 'no remaining multi-cell block disables further charging'); }); test('ending a game during flight cancels the cut and preserves intrinsic floor restrictions', () => { for (const state of ['gameOver', 'gameWin']) { const target = block(5, 0, { floor: true, floorMove: false }); const f = controllerFixture([block(0), target]); startCast(f); f.map[state] = true; f.controller.update(.1); assert.equal(f.controller.casting, false); assert.equal(f.applied.length, 0); assert.equal(target.block_Info.floorMove, false); } }); test('natural floor and freeze state changes during flight are preserved', () => { const target = block(5, 4, { floor: true, floorMove: false }); const f = controllerFixture([block(0), target]); startCast(f); assert.equal(target.type, 4); assert.equal(target.block_Info.floorMove, false); // Another elimination resolves the original restrictions while the spell is in flight. target.type = 0; target.block_Info.type = 0; target.block_Info.floorMove = true; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(target.type, 0); assert.equal(target.block_Info.floorMove, true); const input = inputMethods(f); assert.equal(input.touchStart.call(target, { getLocation: () => v2() }), true); }); test('full bottle waits for release and every countdown pause; freeze elimination adds energy once', () => { const initial = rules.RAINBOW_CAPACITY - rules.RAINBOW_BLOCK_ENERGY[1]; const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = initial; f.map.iceTrue = () => true; f.controller.update(10); assert.equal(f.controller.energy.value, initial); f.controller.eliminated(f.blocks[0].node); f.controller.eliminated(f.blocks[0].node); assert.equal(f.controller.energy.value, rules.RAINBOW_CAPACITY); assert.equal(f.controller.casting, false); f.map.iceTrue = () => false; for (const property of ['pause', 'shopPause', 'touchIng', 'ishammer', 'ismagic']) { f.map[property] = true; f.controller.update(.1); assert.equal(f.controller.casting, false, property); f.map[property] = false; } f.controller.hide(); f.controller.update(10); assert.equal(f.controller.casting, false); f.controller.show(); f.controller.update(.1); assert.equal(f.controller.casting, true); }); test('single cell stacked pair permanently disables; production temporarily disables without losing energy', () => { const lower = block(0, 1), upper = block(0, 10); lower.block_Info.node = upper.node; upper.block_Info.node = lower.node; const f = controllerFixture([lower, upper]); assert.equal(f.controller.energy.stopped, true); const g = controllerFixture(); g.controller.energy.value = 12; g.map.blocks = [block(0).node]; g.map.hasPendingSpawnGateWork = () => true; g.controller.update(2); assert.equal(g.controller.energy.stopped, false); assert.equal(g.controller.energy.unavailable, true); g.map.blocks.push(block().node); g.controller.update(0); assert.equal(g.controller.energy.unavailable, false); assert.equal(g.controller.energy.value, 12); }); test('last rainbow remains required for victory; A retains existing timing behavior', () => { const judgeWin = method(mapFile, 'judgeWin'); let stops = 0; const map = { blocks: [{}], rainbowStreak: {}, hasPendingSpawnGateWork: () => false, stopTimeCutDown() { stops++; }, stopBoom() {} }; judgeWin.call(map, 1); assert.equal(stops, 0); map.rainbowStreak = null; judgeWin.call(map, 1); assert.equal(stops, 1); map.rainbowStreak = {}; map.blocks = []; judgeWin.call(map, 1); assert.equal(stops, 2); }); test('provided prefab slot, bottle, popup progress and settlement nodes are wired', () => { const scene = json('assets/Scene/GameScene.fire'); const map = scene.find(o => o.Block_Prop); assert.equal(map.Block_Prop[24].__uuid__, json('assets/prefab/prop/rainbow.prefab.meta').uuid); assert.ok(scene.some(o => o._name === 'rainbow')); for (const file of ['assets/action_bundle/prefab/winStreak2.prefab', 'assets/win/prefab/Win.prefab']) { const nodes = json(file); assert.ok(nodes.some(o => o._name === 'progress'), file); assert.ok(nodes.some(o => o.__type__ === 'cc.Sprite' && o._type === 3), file); } assert.ok(json('assets/win/prefab/Win.prefab').some(o => o._name === 'WinStreak2')); }); function geometry(shape) { const data = json(`assets/resources/prefab/block/block${shape}.prefab`); const b = block(shape); const rootNode = data.find(o => o.__type__ === 'cc.Node'); function copyNode(raw) { const node = new Node(raw._name); node.setContentSize(raw._contentSize); node.setAnchorPoint(raw._anchorPoint); node.active = raw._active; if (raw._trs) node.setPosition(raw._trs.array[0], raw._trs.array[1]); for (const reference of raw._components || []) { const c = data[reference.__id__]; if (c.__type__ === 'cc.Sprite') node.addComponent(Sprite); else if (c.__type__ === 'cc.PolygonCollider') node.components.PolygonCollider = { points: c.points.map(p => v2(p.x, p.y)), offset: v2((c._offset || c.offset || {}).x, (c._offset || c.offset || {}).y) }; else if (c.data_string !== undefined) node.components.lq_collide = { data_string: c.data_string }; else if (c.heng !== undefined) { b.heng = c.heng; b.shu = c.shu; } } for (const child of raw._children || []) node.addChild(copyNode(data[child.__id__])); return node; } b.node = copyNode(rootNode); b.node.components.Block = b; return b; } function cloneNode(source) { const node = new Node(source.name); node.setPosition(source.position); node.setContentSize(source.getContentSize()); node.setAnchorPoint(source.getAnchorPoint()); node.active = source.active; for (const name of Object.keys(source.components)) node.components[name] = { ...source.components[name] }; source.children.forEach(child => node.addChild(cloneNode(child))); return node; } function cutFixture(shape, type = 0) { const map = { node: new Node('Map'), blocks: [], Block_Array: Array.from({length: 23}, (_, prefabShape) => ({ prefabShape })) }; map.mapBlocksWall = Array.from({length: 15}, (_, x) => Array.from({length: 15}, (_, y) => { const cell = new Node('cell'); cell.setPosition(x * 120, y * 120); cell.components.MapBlock = { block_Id: '', nowColor: -1 }; return cell; })); let effects = 0; map.special_Treatment = map.shrinkVine = map.checkButton = () => { effects++; }; const config = Array.from({length: 23}, () => Object.fromEntries([1,2,3,4,5,6].map(n => ['pos'+n, {x:0,y:0}]))); const cc = { ...engine, fx: { GameConfig: { PROP_INFO: config } }, instantiate(source) { if (source.prefabShape === undefined) return cloneNode(source); const b = geometry(source.prefabShape); b.init = function(info) { assert.deepEqual(Object.keys(info).sort(), ['block','color','id','position','type']); Object.assign(this, { block_Info: info, color: info.color, type: info.type }); initBlocks.call(this); this.posX = (this.node.x - 65) / 120; this.posY = (this.node.y + 60) / 120; this.setMapBlock = place; place.call(this); }; return b.node; } }; const cut = load('assets/Script/RainbowCut.ts', cc, { './RainbowRules': rules, './module/Tool/BlockAssetManager': { default: { instance: { getFrame: frame => frame } } }, './module/Tool/BlockTexturePaths': { blockTexturePath: (color, shape) => color + 'color' + shape } }); function place() { this.allBlocks.forEach(p => Object.assign(map.mapBlocksWall[this.posX+p.x][this.posY+p.y].components.MapBlock, {block_Id:this.node.uuid, nowColor:this.color})); } const b = geometry(shape); b.type = type; b.block_Info.type = type; b.node.setPosition(5*120+65, 5*120-60); map.node.addChild(b.node); b.setMapBlock = place; place.call(b); map.blocks.push(b.node); const hit = new Node('hit'); hit.addComponent(Sprite); b.node.addChild(hit); b.hit = hit; return { map, b, cut, effects: () => effects }; } test('real prefab geometry cuts keep node identity, connected occupancy and colliders before hit', () => { let cases = 0; for (let shape = 1; shape < 23; shape++) for (const choice of rules.rainbowCuts(rules.RAINBOW_SHAPES[shape])) { const f = cutFixture(shape); const node = f.b.node; const before = pointsKey(f.b.allBlocks.map(p => ({x:p.x+5,y:p.y+5}))); const rainbow = f.cut.applyRainbowCut(f.map, f.b, choice); assert.equal(f.b.node, node); assert.equal(f.b.block_Info.block, choice.shape); const after = f.map.blocks.flatMap(n => n.components.Block.allBlocks.map(p => ({x:n.components.Block.posX+p.x,y:n.components.Block.posY+p.y}))); assert.equal(pointsKey(after), before); for (const n of f.map.blocks) n.components.Block.allBlocks.forEach(p => { const b = n.components.Block; assert.equal(f.map.mapBlocksWall[b.posX+p.x][b.posY+p.y].components.MapBlock.block_Id, n.uuid); }); const sample = geometry(choice.shape); assert.deepEqual(plain(node.components.PolygonCollider), plain(sample.node.components.PolygonCollider)); for (const child of node.children.filter(c => c.getComponent('lq_collide'))) assert.ok(node.children.indexOf(child) < node.children.indexOf(f.b.hit)); assert.equal(rainbow.components.Block.color, 100); assert.equal(rainbow.components.Block.type, 0); assert.equal(f.effects(), 0); cases++; } assert.ok(cases > 40); }); test('stationary floor and adhesive cuts rebuild linked offsets and collider proxies, including reverse-only floor links', () => { const globals = { cc: { ...engine, instantiate: cloneNode }, setTimeout: callback => callback(), BlockType: { 叠加块下: 1, 叠加块上: 10, 冻结块: 4, 粘合块: 9, 变色块: 6, 消除炸弹块: 17 }, LQCollideSystem: { update_logic() {} } }; const setMoveFloor = method(blockFile, 'setMoveFloor', globals); const createNianHe = method(blockFile, 'createNianHe', globals); const update = method(blockFile, 'update', globals); const resetFloor = method(blockFile, 'resetFloor', globals); for (const mode of ['reverse_floor', 'floor', 'adhesive']) { const f = cutFixture(3), a = f.b, b = geometry(2); b.node.setPosition(a.node.x + 360, a.node.y + 240); f.map.node.addChild(b.node); f.map.blocks.push(b.node); for (const member of [a, b]) Object.assign(member, { setFloorSprite() {}, touchEnd() {}, setMoveFloor, createNianHe, touchDelta: v2() }); if (mode === 'adhesive') { a.type = b.type = 9; a.block_Info.node = b.node; b.block_Info.node = a.node; a.block_Info.lockTime = 7; b.block_Info.lockTime = 5; a.createNianHe(false); b.createNianHe(false); } else { a.teamBlocks = b.teamBlocks = [a.node, b.node]; Object.assign(a.block_Info, { floor: 3, floorMove: true, floorTime: 2 }); Object.assign(b.block_Info, { floor: 3, floorMove: true, floorTime: 5 }); a.setMoveFloor(); b.setMoveFloor(); if (mode === 'reverse_floor') { resetFloor.call(a); assert.equal(a.teamBlocks.length, 0); assert.deepEqual(b.teamBlocks, [a.node, b.node]); } } const tag = mode === 'adhesive' ? '-1' : '-2'; const oldProxies = b.node.children.filter(n => n.getComponent('lq_collide')?.data_string === tag); const originalX = a.node.x; const cut = rules.rainbowCuts(a.allBlocks).find(c => c.offset.x !== 0); assert.ok(cut); const rainbow = f.cut.applyRainbowCut(f.map, a, cut); assert.equal(a.node.x, originalX + cut.offset.x * 120); const offset = mode === 'adhesive' ? b.adhesive : b.floorOffset[0]; assert.equal(b.node.x - offset.x, a.node.x, mode); assert.equal(b.node.y - offset.y, a.node.y, mode); assert.ok(oldProxies.every(n => n.destroyed), mode + ': old proxies removed'); const bases = a.node.children.filter(n => ['top','down','left','right'].includes(n.name) && !['-1','-2'].includes(n.getComponent('lq_collide')?.data_string)); const proxies = b.node.children.filter(n => !n.destroyed && n.getComponent('lq_collide')?.data_string === tag); assert.equal(proxies.length, bases.length, mode + ': exactly one new proxy per base collider'); for (const base of bases) { const proxy = proxies.find(n => n.name === base.name); assert.equal(b.node.x + proxy.x, a.node.x + base.x); assert.equal(b.node.y + proxy.y, a.node.y + base.y); assert.deepEqual(proxy.getContentSize(), base.getContentSize()); } if (mode === 'adhesive') { assert.equal(a.block_Info.node, b.node); assert.equal(b.block_Info.node, a.node); assert.equal(a.block_Info.lockTime, 7); assert.equal(b.block_Info.lockTime, 5); } else { assert.equal(b.block_Info.floorTime, 5); assert.equal(b.block_Info.floorMove, true); assert.deepEqual(b.teamBlocks, [a.node, b.node]); assert.equal(a.block_Info.floorTime, mode === 'reverse_floor' ? null : 2); assert.deepEqual(Array.from(a.teamBlocks), mode === 'reverse_floor' ? [] : [a.node, b.node]); } const remainingPosition = a.node.position, rainbowPosition = rainbow.position; b.isTouch = true; b.node.x += 10; update.call(b, 1 / 60); assert.deepEqual(plain(a.node.position), { x: remainingPosition.x + 10, y: remainingPosition.y }, mode + ': no jump to old origin'); assert.deepEqual(plain(rainbow.position), plain(rainbowPosition), 'rainbow stays independent of the group'); } }); test('stack cuts both layers at the same cell, preserves relationships and yields exactly one rainbow', () => { const f = cutFixture(1, 1); const upper = geometry(1); upper.type = 10; upper.node.scaleX = upper.node.scaleY = .7; f.map.node.addChild(upper.node); f.map.blocks.push(upper.node); f.b.block_Info.node = upper.node; upper.block_Info.node = f.b.node; f.cut.applyRainbowCut(f.map, f.b, rules.rainbowCuts(f.b.allBlocks)[0]); assert.equal(f.map.blocks.length, 3); assert.equal(f.b.allBlocks.length + upper.allBlocks.length + 1, 3); assert.equal(f.b.block_Info.node, upper.node); assert.equal(upper.block_Info.node, f.b.node); assert.equal(upper.node.scaleX, .7); assert.equal(upper.type, 10); assert.equal(f.b.type, 1); assert.equal(f.b.posX, upper.posX); assert.equal(f.b.posY, upper.posY); assert.equal(f.effects(), 0); }); test('cut retains floor and bomb component state and removes only the cut cell floor without effects', () => { const f = cutFixture(3, 17); f.b.block_Info.floor = true; const floors = f.b.allBlocks.map(p => { const node = new Node('floor'); node.setPosition(-65+p.x*120,60+p.y*120); node.components.Floor = {time:7, color:3, posX:5+p.x, posY:5+p.y}; f.b.node.addChild(node); return node; }); const boom = new Node('boom2'); boom.components.Boom = {time:4, over:false}; f.b.node.addChild(boom); const state = boom.components.Boom; const choice = rules.rainbowCuts(f.b.allBlocks)[0]; f.cut.applyRainbowCut(f.map, f.b, choice); assert.equal(boom.components.Boom, state); assert.equal(state.time, 4); assert.equal(f.b.type, 17); assert.equal(f.b.block_Info.floor, true); assert.equal(floors.filter(n => n.destroyed).length, 1); floors.filter(n => !n.destroyed).forEach(n => { assert.equal(n.components.Floor.time, 7); assert.equal(n.components.Floor.color, 3); assert.equal(n.components.Floor.posX, f.b.posX + Math.round((n.x+65)/120)); }); assert.equal(f.effects(), 0); }); test('finishing a cut keeps an immovable floor remainder immovable while its new rainbow accepts dragging', () => { const f = cutFixture(5); Object.assign(f.b.block_Info, { floor: true, floorMove: false, floorTime: 7 }); let rainbow; const c = controllerFixture([block(0), f.b], (_map, target, choice) => { rainbow = f.cut.applyRainbowCut(f.map, target, choice).getComponent('Block'); }); const input = inputMethods(c); const press = { getLocation: () => v2() }; startCast(c); assert.equal(input.touchStart.call(f.b, press), undefined, 'intrinsic immovable floor still rejects touch'); c.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.b.block_Info.floorMove, false); assert.equal(f.b.block_Info.floorTime, 7); assert.equal(f.b.block_Info.floor, true); const after = inputMethods(c, [f.b, rainbow]); assert.equal(after.touchStart.call(f.b, press), undefined, 'original floor movement rule still rejects touch'); assert.notEqual(f.b.isTouch, true); assert.equal(rainbow.type, 0); assert.equal(rainbow.color, 100); assert.equal(rainbow.block_Info.floor, undefined); assert.equal(rainbow.block_Info.floorMove, undefined); assert.equal(after.touchStart.call(rainbow, press), true); const before = rainbow.node.position; after.touchMove.call(rainbow, { getLocation: () => v2(10, 10), getDelta: () => v2() }); after.update.call(rainbow, 1 / 60); assert.deepEqual(plain(rainbow.node.position), { x: before.x + 10, y: before.y + 10 }); }); test('rainbow elimination advances numeric counters and vines, never switches or count bombs', () => { const f = cutFixture(1); const removed = block(0).node; const amounts = {}; for (const name of ['Freeze','Question','Floor','Lock','Heng','Shu','Boom','Switchs']) { const node = new Node(name); node.components[name] = { time:3, color:8, reduce(...args) { amounts[name]=args; } }; f.b.node.addChild(node); } let vines=0, walls=0; f.map.blocks.push(removed); f.map.shrinkVine=()=>vines++; f.map.changeFreeze=f.map.changeLock=f.map.changeAdhesive=()=>walls++; f.cut.applyRainbowElimination(f.map, removed); assert.equal(vines,1); assert.equal(walls,3); assert.equal(f.map.blocks.includes(removed),false); assert.deepEqual(plain(amounts), {Freeze:[1,true],Question:[1,true],Floor:[1,8],Lock:[],Heng:[],Shu:[]}); const changeBoom=method(mapFile,'changeBoom'); changeBoom.call({}, {getComponent:()=>({color:100})}); }); test('only layer_two 2 replaces the reward, with original streak/mainline/unlock eligibility', () => { const file = 'assets/Script/module/Config/GameConfig.ts'; const config = { isRainbowWinStreak: method(file,'isRainbowWinStreak'), isWinStreakUnlocked: () => true }; const active = method(file,'isRainbowRewardActive'); for (const group of [undefined, 1, 2, 3]) { config.GM_INFO = { abTests: {layer_two:group}, winStreak:10, otherLevel:0 }; assert.equal(active.call(config), group === 2); } config.GM_INFO.abTests.layer_two = 2; config.GM_INFO.winStreak = 9; assert.equal(active.call(config),false); config.GM_INFO.winStreak = 10; config.GM_INFO.otherLevel = 5; assert.equal(active.call(config),false); config.GM_INFO.otherLevel = 0; config.isWinStreakUnlocked=()=>false; assert.equal(active.call(config),false); }); test('normal casts wait for release animation; a prior partial hammer hit does not exclude the remaining block', () => { const f = controllerFixture([block(), block()]); f.controller.first = false; f.blocks[0].isEliminatedByHammer = true; // Existing field also stays true after a partial lock hit. assert.equal(f.controller.candidates().length,2); const exit = block().node; exit.components.Block.over = true; f.map.node.children.push(exit); f.controller.update(.1); assert.equal(f.controller.casting,false); exit.active = false; f.controller.update(.1); assert.equal(f.controller.casting,true); f.map.pause = true; f.controller.update(10); assert.equal(f.applied.length,0); f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length,1); }); test('browser startup has ten wins before entering GameScene; disabling test mode restores normal defaults', () => { for (const enabled of [true, false]) { const config = { forceRainbowWinStreakB: enabled }; const cc = { resources: { load() {} }, fx: { GameConfig: config, GameTool: { getHealth() {} } } }; method('assets/Script/module/Config/GameConfig.ts', 'GM_INFO_init', { cc }).call(config); const manager = {}; // No wx/tt globals: exercise the browser's actual startup branch. method('assets/Script/GameManager.ts', 'readUserData', { cc }).call(manager); assert.equal(manager.load3, true); assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0); assert.equal(config.GM_INFO.winState, enabled); } }); test('home restores test streak before rendering, even when login or a previous loss supplied zero', () => { for (const enabled of [true, false]) { const config = { forceRainbowWinStreakB: enabled, GM_INFO: { winStreak: 0, winState: false } }; const beforeRendering = new Error('UI boundary'); const onLoad = method('assets/Script/JiaZai.ts', 'onLoad', { cc: { fx: { GameConfig: config } } }); assert.throws(() => onLoad.call({ ensureLoadingOnTop() { throw beforeRendering; } }), error => error === beforeRendering); assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0); assert.equal(config.GM_INFO.winState, enabled); } }); test('rainbow elimination includes gray and monochrome obstacle counters without counting them as clear targets', () => { const f = cutFixture(1); const removed = block(0).node; f.map.blocks.push(removed); f.map.node.addChild(removed); f.map.shrinkVine = f.map.changeFreeze = f.map.changeLock = f.map.changeAdhesive = () => {}; const targets = [block(1, 4), block(1, 16), block(1, 22), block(), block(), block()]; targets[0].color = targets[1].color = 11; targets[3].spawnLocked = true; targets[4].over = true; targets[5].node.destroyed = true; for (const target of [...targets, removed.getComponent('Block')]) { for (const name of ['Freeze', 'Question', 'Floor', 'Lock', 'Heng', 'Shu', 'Boom']) { const child = new Node(name); child.components[name] = { time: 5, color: 3, reduce() { this.time--; } }; target.node.addChild(child); } if (target.node !== removed) f.map.node.addChild(target.node); } f.cut.applyRainbowElimination(f.map, removed); assert.deepEqual(f.map.blocks, [f.b.node]); for (const [index, target] of [...targets, removed.getComponent('Block')].entries()) { for (const child of target.node.children) { const expected = index < 3 && child.name !== 'Boom' ? 4 : 5; assert.equal(child.components[child.name].time, expected, index + ':' + child.name); } } }); test('test switch keeps ten wins through wins and losses; disabling restores normal settlement', () => { const source = ts.createSourceFile('GameTool.ts', read('assets/Script/module/Tool/GameTool.ts'), ts.ScriptTarget.Latest, true); const declaration = source.statements.filter(ts.isVariableStatement) .flatMap(node => [...node.declarationList.declarations]).find(node => node.name.getText(source) === 'GameTool'); const member = declaration.initializer.properties.find(node => node.name.getText(source) === 'setWinStreak'); const config = { forceRainbowWinStreakB: true, GM_INFO: { winStreak: 0, winState: false } }; const saved = {}; let uploads = 0; const settle = vm.runInNewContext('({' + member.getText(source) + '}).setWinStreak', { cc: { fx: { GameConfig: config, StorageMessage: { setStorage: (key, value) => saved[key] = value } } }, Utils: { setWinStreak() { uploads++; } } }); for (const result of ['fail', 'sucess', 'fail', 'fail', 'sucess']) { settle(result); assert.equal(config.GM_INFO.winStreak, 10); assert.equal(config.GM_INFO.winState, true); assert.deepEqual(saved, { winStreak: 10, winState: true }); } assert.equal(uploads, 0); config.forceRainbowWinStreakB = false; settle('fail'); assert.equal(config.GM_INFO.winStreak, 0); assert.equal(config.GM_INFO.winState, false); assert.deepEqual(saved, { winStreak: 0, winState: false }); config.GM_INFO.winStreak = 9; settle('sucess'); assert.equal(config.GM_INFO.winStreak, 10); assert.equal(config.GM_INFO.winStreakFirst, true); assert.equal(uploads, 2); });