// 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 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, 9); energy.tick(20, true); assert.equal(energy.value, 29); energy.eliminated(10); assert.equal(energy.value, 30); assert.equal(energy.consume(), true); assert.equal(energy.consume(), false); energy.tick(5, true); energy.setAvailability(false, true); energy.tick(100, true); energy.eliminated(10); assert.equal(energy.value, 5); energy.setAvailability(true, false); energy.tick(25, true); assert.equal(energy.value, 30); 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); }); 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); } 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: [], 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(), block()]) { const applied = []; const Controller = load('assets/Script/RainbowStreak.ts', engine, { './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => applied.push(args) }, './RainbowTrail': trailModule }).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 }; } test('first press excludes the whole dragged group; 1.5 s flight keeps input locked until the cut', () => { const f = controllerFixture(); const first = f.blocks[0]; first.isTouch = true; first.node.parent = new Node(); first.node.setPosition(30, 40); f.map.touchIng = true; f.controller.onPress(first, { getLocation: () => v2(30, 40) }); assert.equal(f.controller.casting, true); assert.equal(f.controller.selection.block, f.blocks[1]); assert.equal(f.map.pause, undefined); assert.equal(f.controller.energy.value, 0); f.controller.capture(first, { getLocation: () => v2(100, 200) }, false); f.controller.update(1.49); assert.equal(f.applied.length, 0); assert.equal(f.controller.casting, true); assert.equal(f.controller.shield.active, true); f.controller.update(.01); assert.equal(f.applied.length, 1); assert.equal(f.controller.casting, false); assert.equal(f.controller.shield, null); assert.deepEqual(plain(first.relative_Position), { x: -70, y: -160 }); assert.equal(first.touchDelta.mag(), 0); assert.equal(first.isTouch, true); }); 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; })); f.controller.onPress(f.blocks[0], { getLocation: () => v2() }); 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); f.controller.update(.4); const position = plain(trail.node.position); f.map.pause = true; f.controller.update(10); assert.equal(f.applied.length, 0); assert.equal(f.controller.shield.active, true); assert.deepEqual(plain(trail.node.position), position); assert.equal(trail.node.active, false); f.map.pause = false; f.controller.update(1.1); assert.equal(f.applied.length, 1); assert.equal(f.controller.shield, null); 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 and input shield immediately', () => { const f = controllerFixture(); f.controller.onPress(f.blocks[0], { getLocation: () => v2() }); const trail = f.controller.flight = new trailModule.default(new Node('effects'), rules.RAINBOW_CAST_SECONDS); const shield = f.controller.shield; 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(shield.active, false); assert.equal(shield.destroyed, true); assert.equal(trail.node.destroyed, true); assert.ok(textures.every(texture => texture.destroyed)); assert.equal(f.controller.casting, false); }); test('release during flight settles once; lost target consumes cast; only dragged group consumes without locking', () => { const f = controllerFixture(); const first = f.blocks[0]; first.isTouch = true; first.node.parent = new Node(); let ends = 0; first.touchEnd = () => { ends++; first.isTouch = false; }; f.controller.onPress(first, { getLocation: () => v2() }); f.controller.capture(first, { getLocation: () => v2(4, 5) }, true); f.map.blocks.pop(); f.controller.update(rules.RAINBOW_CAST_SECONDS); f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(ends, 1); assert.equal(f.applied.length, 0); assert.equal(f.controller.energy.value, 2 * rules.RAINBOW_CAST_SECONDS); const linked = controllerFixture(); linked.blocks[0].teamBlocks = linked.map.blocks; linked.controller.onPress(linked.blocks[0], { getLocation: () => v2() }); assert.equal(linked.controller.energy.value, 0); assert.equal(linked.controller.casting, false); }); test('full bottle waits for release and every countdown pause; freeze elimination adds energy once', () => { const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = 27; f.map.iceTrue = () => true; f.controller.update(10); assert.equal(f.controller.energy.value, 27); f.controller.eliminated(f.blocks[0].node); f.controller.eliminated(f.blocks[0].node); assert.equal(f.controller.energy.value, 30); 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('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('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(); 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); } });