MatchMaster/tools/test-rainbow-streak.cjs
2026-09-18 18:32:09 +08:00

1211 lines
72 KiB
JavaScript

// 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'));
});
test('pause exit and restart select rainbow confirmation only for B and preserve confirmation actions', () => {
const data = json('assets/pause/prefab/Pause.prefab');
const binding = data.find(o => o.win && o.exit);
assert.equal(data[binding.win2.__id__]._name, 'WinStreak2');
function inflate(index, nodes) {
const raw = data[index], node = new Node(raw._name);
nodes.set(index, node);
node.active = raw._active;
for (const r of raw._components) node.components[data[r.__id__].__type__] = data[r.__id__];
for (const r of raw._children) node.addChild(inflate(r.__id__, nodes));
return node;
}
for (const [group, forced] of [[1, false], [2, false], [3, false], [1, true]]) {
for (const power of [false, true]) {
const nodes = new Map(), root = inflate(1, nodes), calls = [];
const config = {
forceRainbowWinStreakB: forced, GM_INFO: { abTests: { layer_two: group }, winStreak: 10, otherLevel: 0 },
isRainbowWinStreak: method('assets/Script/module/Config/GameConfig.ts', 'isRainbowWinStreak'),
};
const map = { gameStart: true, trackFinishi() {}, returnHome: () => calls.push('home'), againLevel: () => calls.push('restart') };
const globals = { MapConroler: { _instance: map }, console: { log() {} }, cc: {
fx: { GameConfig: config, AudioManager: { _instance: { playEffect() {} } },
GameTool: { getUserPowerTime: () => power, setWinStreak: state => calls.push(state) } },
tween: () => ({ to() { return this; }, start() {} }),
} };
const setting = {};
for (const name of ['music', 'effect', 'vibrate', 'exit', 'win', 'win2']) setting[name] = nodes.get(binding[name].__id__);
for (const name of ['onLoad', 'clickExit', 'clickRestart', 'cancelExit', 'returnHome']) setting[name] = method('assets/Script/setUi.ts', name, globals);
setting.onLoad();
setting.clickExit();
const selected = root.getChildByName(group === 2 || forced ? 'WinStreak2' : 'pauseWin');
const other = root.getChildByName(group === 2 || forced ? 'pauseWin' : 'WinStreak2');
assert.equal(selected.active, true); assert.equal(other.active, false);
const health = selected.getChildByName('Health');
assert.equal(health.getChildByName('queding').active, power);
const click = name => {
const button = health.getChildByName(name).getComponent('cc.Button');
const event = data[button.clickEvents[0].__id__];
assert.equal(event.target.__id__, 1);
assert.equal(event._componentId, binding.__type__);
setting[event.handler]({}, event.customEventData);
};
click('timeBtn'); assert.equal(selected.active, false); assert.deepEqual(calls, []);
setting.clickExit(); click('return'); assert.deepEqual(calls, ['fail', 'home']);
setting.cancelExit(); calls.length = 0;
setting.clickRestart({}, '');
assert.equal(selected.active, true); assert.equal(health.getChildByName('queding').active, true);
click('queding'); assert.deepEqual(calls, ['fail', 'restart']);
setting.cancelExit(); config.GM_INFO.winStreak = 9;
calls.length = 0; setting.clickExit();
assert.equal(selected.active, false);
assert.equal(setting.exit.active, !power);
assert.deepEqual(calls, power ? ['home'] : []);
}
}
});
for (const [variant, group, forced] of [['A', 1, false], ['B', 2, false], ['C', 3, false], ['forced B', 1, true]]) {
test(`${variant} bottom layout keeps buttons, masks and hammer cancel aligned`, () => {
const scene = json('assets/Scene/GameScene.fire');
function copyNode(raw) {
const node = new Node(raw._name);
node.setPosition(raw._trs.array[0], raw._trs.array[1]);
node.active = raw._active;
for (const child of raw._children) node.addChild(copyNode(scene[child.__id__]));
return node;
}
const gameNode = copyNode(scene.find(o => o._name === 'GameNode'));
const bottom = gameNode.getChildByName('Bottom');
const find = (path, root) => path.split('/').reduce((node, name) => node.getChildByName(name), root);
const before = Object.fromEntries(bottom.children.map(node => [node.name, { x: node.x, y: node.y, active: node.active }]));
const config = {
GM_INFO: { abTests: { layer_two: group } }, forceRainbowWinStreakB: forced,
isRainbowWinStreak: method('assets/Script/module/Config/GameConfig.ts', 'isRainbowWinStreak'),
};
const layout = method(mapFile, 'initBottomLayout', { cc: { fx: { GameConfig: config }, find } });
const map = { node: gameNode.getChildByName('Map') };
layout.call(map);
const rainbow = group === 2 || forced;
const names = ['destroyBtn', 'timeBtn', 'magicBtn', 'returnBtn'];
const positions = names.map(name => bottom.getChildByName(name).x);
if (rainbow) assert.deepEqual(positions, names.map(name => before[name].x));
else {
assert.equal(positions.reduce((sum, x) => sum + x, 0), 0, 'four slots centered');
assert.equal(positions[1] - positions[0], 230);
assert.equal(positions[2] - positions[1], 230);
assert.equal(positions[3] - positions[2], 230);
}
for (const [follower, leader] of Object.entries({
winStreakBtn: 'destroyBtn', hammerMask: 'destroyBtn', freezeMask: 'timeBtn',
rippleShrink: 'timeBtn', magicMask: 'magicBtn', pauseBtn: 'returnBtn',
})) {
const expected = before[follower].x - before[leader].x;
const actual = bottom.getChildByName(follower).x - bottom.getChildByName(leader).x;
assert.ok(Math.abs(actual - expected) < 1e-9, follower);
}
for (const node of bottom.children) {
const pauseOffset = !rainbow && ['returnBtn', 'pauseBtn'].includes(node.name)
? before.magicBtn.y - before.returnBtn.y : 0;
assert.equal(node.y, before[node.name].y + pauseOffset, node.name + ' height');
if (!['rainbow', 'rainbowBg'].includes(node.name)) assert.equal(node.active, before[node.name].active);
if (rainbow) assert.equal(node.x, before[node.name].x, node.name + ' B position unchanged');
}
assert.equal(bottom.getChildByName('rainbow').active, false, 'water waits for eligible reward');
assert.equal(bottom.getChildByName('rainbowBg').active, rainbow);
const cancel = find('Mask/bottom/destroyBtn', gameNode);
assert.deepEqual(plain(cancel.position), plain(bottom.getChildByName('destroyBtn').position));
const after = bottom.children.map(node => [node.x, node.y]);
layout.call(map);
assert.deepEqual(bottom.children.map(node => [node.x, node.y]), after, 'reinitialization must not drift');
});
}
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);
});