MatchMaster/tools/test-rainbow-streak.cjs
WIN-GKKD951VVMJ\Administrator 28215af395 时间拉长
2026-09-15 17:23:46 +08:00

438 lines
28 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 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) { Object.assign(this, 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; } }
class PolygonCollider {}
const engine = { Node, Sprite, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b) => ({r,g,b}),
Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999 }, isValid: node => !!node && !node.destroyed,
game: { on() {}, off() {}, EVENT_HIDE: 'hide', EVENT_SHOW: 'show' }, find: () => new Node('Canvas') };
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) } }).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('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);
}
});