503 lines
31 KiB
JavaScript
503 lines
31 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, height) { Object.assign(this, typeof size === 'number' ? { width: size, height } : size); }
|
|
getAnchorPoint() { return v2(this.anchorX, this.anchorY); }
|
|
setAnchorPoint(p) { this.anchorX = p.x; this.anchorY = p.y; }
|
|
getChildByName(name) { return this.children.find(n => n.name === name); }
|
|
getComponent(name) { return this.components[typeof name === 'string' ? name : name.name] || null; }
|
|
addComponent(name) { const c = new name(); c.node = this; this.components[name.name] = c; return c; }
|
|
addChild(child) { child.parent = this; if (!this.children.includes(child)) this.children.push(child); }
|
|
setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this); }
|
|
removeFromParent() { if (this.parent) this.parent.children = this.parent.children.filter(n => n !== this); this.parent = null; }
|
|
destroy() { this.destroyed = true; }
|
|
getNumberOfRunningActions() { return 0; }
|
|
convertToNodeSpaceAR(p) { return p.clone(); }
|
|
convertToWorldSpaceAR(p) { return p.clone(); }
|
|
}
|
|
class Sprite { constructor() { this.fillRange = 1; } }
|
|
Sprite.SizeMode = { CUSTOM: 0 };
|
|
class Texture2D {
|
|
initWithData(pixels, format, width, height) { Object.assign(this, { pixels, format, width, height }); }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
Texture2D.PixelFormat = { RGBA8888: 6 };
|
|
class SpriteFrame {
|
|
constructor(texture) { this.texture = texture; }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
class ParticleSystem {
|
|
constructor() { this.resets = 0; this.particleCount = 0; }
|
|
resetSystem() { this.resets++; this.active = true; }
|
|
stopSystem() { this.active = false; }
|
|
}
|
|
ParticleSystem.PositionType = { FREE: 0 };
|
|
ParticleSystem.EmitterMode = { GRAVITY: 0 };
|
|
class PolygonCollider {}
|
|
const engine = { Node, Sprite, Texture2D, SpriteFrame, ParticleSystem, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b, a = 255) => ({r,g,b,a}),
|
|
Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771 }, isValid: node => !!node && !node.destroyed,
|
|
game: { on() {}, off() {}, EVENT_HIDE: 'hide', EVENT_SHOW: 'show' }, find: () => new Node('Canvas') };
|
|
const trailModule = load('assets/Script/RainbowTrail.ts', engine);
|
|
function block(shape = 1, type = 0, info = {}) {
|
|
const node = new Node('block' + shape);
|
|
const b = { node, block_Info: { block: shape, type, color: 2, ...info }, type, color: 2, posX: 5, posY: 5,
|
|
teamBlocks: [], adhesiveNode: [], allBlocks: [], blockId: Number(node.uuid), initBlocks,
|
|
ice_SpriteFrame: { getSpriteFrame: name => name }, flower_SpriteFrame: { getSpriteFrame: name => name },
|
|
setHitPosition() {}, setMapBlock() {}, cmupdate() {}, getStackingPos: () => v2(-21,22) };
|
|
initBlocks.call(b); node.components.Block = b;
|
|
return b;
|
|
}
|
|
function controllerFixture(blocks = [block(), block()]) {
|
|
const applied = [];
|
|
const Controller = load('assets/Script/RainbowStreak.ts', engine,
|
|
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => applied.push(args) }, './RainbowTrail': trailModule }).default;
|
|
const map = { blocks: blocks.map(b => b.node), gameStart: true, scheduleCallback() {}, timeNumber: 100,
|
|
hasPendingSpawnGateWork: () => false, isPauseOpen: () => false, iceTrue: () => false, magicMask: { active: false } };
|
|
map.node = new Node('Map'); map.node.children = map.blocks;
|
|
const bottle = new Node('bottle'); bottle.addComponent(Sprite);
|
|
const controller = new Controller(map, bottle);
|
|
return { controller, map, bottle, applied, blocks };
|
|
}
|
|
|
|
test('first press excludes the whole dragged group; 1.5 s flight keeps input locked until the cut', () => {
|
|
const f = controllerFixture(); const first = f.blocks[0];
|
|
first.isTouch = true; first.node.parent = new Node(); first.node.setPosition(30, 40);
|
|
f.map.touchIng = true;
|
|
f.controller.onPress(first, { getLocation: () => v2(30, 40) });
|
|
assert.equal(f.controller.casting, true); assert.equal(f.controller.selection.block, f.blocks[1]);
|
|
assert.equal(f.map.pause, undefined); assert.equal(f.controller.energy.value, 0);
|
|
f.controller.capture(first, { getLocation: () => v2(100, 200) }, false);
|
|
f.controller.update(1.49); assert.equal(f.applied.length, 0);
|
|
assert.equal(f.controller.casting, true); assert.equal(f.controller.shield.active, true);
|
|
f.controller.update(.01); assert.equal(f.applied.length, 1); assert.equal(f.controller.casting, false);
|
|
assert.equal(f.controller.shield, null);
|
|
assert.deepEqual(plain(first.relative_Position), { x: -70, y: -160 });
|
|
assert.equal(first.touchDelta.mag(), 0); assert.equal(first.isTouch, true);
|
|
});
|
|
|
|
test('rainbow trail pauses without restarting, fades after arrival even when bottle stops, and releases textures', () => {
|
|
const f = controllerFixture();
|
|
const parent = new Node('effects');
|
|
f.map.magics = new Node('magics'); parent.addChild(f.map.magics);
|
|
f.map.mapBlocksWall = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => {
|
|
const cell = new Node('cell'); parent.addChild(cell); return cell;
|
|
}));
|
|
f.controller.onPress(f.blocks[0], { getLocation: () => v2() });
|
|
const trail = f.controller.flight;
|
|
const particles = trail.node.children.map(node => node.getComponent(ParticleSystem)).filter(Boolean);
|
|
const frames = particles.map(p => p.spriteFrame);
|
|
particles.forEach(p => p.particleCount = 8);
|
|
f.controller.update(.4);
|
|
const position = plain(trail.node.position);
|
|
f.map.pause = true; f.controller.update(10);
|
|
assert.equal(f.applied.length, 0); assert.equal(f.controller.shield.active, true);
|
|
assert.deepEqual(plain(trail.node.position), position);
|
|
assert.equal(trail.node.active, false);
|
|
f.map.pause = false; f.controller.update(1.1);
|
|
assert.equal(f.applied.length, 1); assert.equal(f.controller.shield, null);
|
|
assert.equal(trail.node.active, true); assert.equal(trail.node.destroyed, undefined);
|
|
assert.ok(particles.every(p => !p.active && p.resets === 1));
|
|
assert.equal(trail.node.getChildByName('rainbowHead').active, false);
|
|
f.controller.energy.stopped = true;
|
|
f.controller.hide(); f.controller.update(10);
|
|
assert.equal(trail.node.destroyed, undefined);
|
|
f.controller.show(); f.controller.update(1);
|
|
assert.equal(trail.node.destroyed, undefined);
|
|
particles.forEach(p => p.particleCount = 0);
|
|
f.controller.update(.1);
|
|
assert.equal(trail.node.destroyed, true);
|
|
assert.ok(frames.every(frame => frame.destroyed && frame.texture.destroyed));
|
|
assert.equal(f.controller.fadingFlights.length, 0);
|
|
});
|
|
|
|
test('scene exit disposes an in-flight rainbow effect and input shield immediately', () => {
|
|
const f = controllerFixture();
|
|
f.controller.onPress(f.blocks[0], { getLocation: () => v2() });
|
|
const trail = f.controller.flight = new trailModule.default(new Node('effects'), rules.RAINBOW_CAST_SECONDS);
|
|
const shield = f.controller.shield;
|
|
const textures = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean).map(p => p.spriteFrame.texture);
|
|
f.controller.dispose();
|
|
assert.equal(f.applied.length, 0); assert.equal(shield.active, false); assert.equal(shield.destroyed, true);
|
|
assert.equal(trail.node.destroyed, true); assert.ok(textures.every(texture => texture.destroyed));
|
|
assert.equal(f.controller.casting, false);
|
|
});
|
|
|
|
test('release during flight settles once; lost target consumes cast; only dragged group consumes without locking', () => {
|
|
const f = controllerFixture(); const first = f.blocks[0];
|
|
first.isTouch = true; first.node.parent = new Node(); let ends = 0;
|
|
first.touchEnd = () => { ends++; first.isTouch = false; };
|
|
f.controller.onPress(first, { getLocation: () => v2() });
|
|
f.controller.capture(first, { getLocation: () => v2(4, 5) }, true);
|
|
f.map.blocks.pop(); f.controller.update(rules.RAINBOW_CAST_SECONDS); f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(ends, 1); assert.equal(f.applied.length, 0); assert.equal(f.controller.energy.value, 2 * rules.RAINBOW_CAST_SECONDS);
|
|
const linked = controllerFixture(); linked.blocks[0].teamBlocks = linked.map.blocks;
|
|
linked.controller.onPress(linked.blocks[0], { getLocation: () => v2() });
|
|
assert.equal(linked.controller.energy.value, 0); assert.equal(linked.controller.casting, false);
|
|
});
|
|
|
|
test('full bottle waits for release and every countdown pause; freeze elimination adds energy once', () => {
|
|
const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = 27;
|
|
f.map.iceTrue = () => true;
|
|
f.controller.update(10); assert.equal(f.controller.energy.value, 27);
|
|
f.controller.eliminated(f.blocks[0].node); f.controller.eliminated(f.blocks[0].node);
|
|
assert.equal(f.controller.energy.value, 30); assert.equal(f.controller.casting, false);
|
|
f.map.iceTrue = () => false;
|
|
for (const property of ['pause', 'shopPause', 'touchIng', 'ishammer', 'ismagic']) {
|
|
f.map[property] = true; f.controller.update(.1); assert.equal(f.controller.casting, false, property); f.map[property] = false;
|
|
}
|
|
f.controller.hide(); f.controller.update(10); assert.equal(f.controller.casting, false);
|
|
f.controller.show(); f.controller.update(.1); assert.equal(f.controller.casting, true);
|
|
});
|
|
|
|
test('single cell stacked pair permanently disables; production temporarily disables without losing energy', () => {
|
|
const lower = block(0, 1), upper = block(0, 10); lower.block_Info.node = upper.node; upper.block_Info.node = lower.node;
|
|
const f = controllerFixture([lower, upper]); assert.equal(f.controller.energy.stopped, true);
|
|
const g = controllerFixture(); g.controller.energy.value = 12; g.map.blocks = [block(0).node];
|
|
g.map.hasPendingSpawnGateWork = () => true; g.controller.update(2);
|
|
assert.equal(g.controller.energy.stopped, false); assert.equal(g.controller.energy.unavailable, true);
|
|
g.map.blocks.push(block().node); g.controller.update(0); assert.equal(g.controller.energy.unavailable, false); assert.equal(g.controller.energy.value, 12);
|
|
});
|
|
|
|
test('last rainbow remains required for victory; A retains existing timing behavior', () => {
|
|
const judgeWin = method(mapFile, 'judgeWin');
|
|
let stops = 0;
|
|
const map = { blocks: [{}], rainbowStreak: {}, hasPendingSpawnGateWork: () => false,
|
|
stopTimeCutDown() { stops++; }, stopBoom() {} };
|
|
judgeWin.call(map, 1); assert.equal(stops, 0);
|
|
map.rainbowStreak = null; judgeWin.call(map, 1); assert.equal(stops, 1);
|
|
map.rainbowStreak = {}; map.blocks = []; judgeWin.call(map, 1); assert.equal(stops, 2);
|
|
});
|
|
|
|
test('provided prefab slot, bottle, popup progress and settlement nodes are wired', () => {
|
|
const scene = json('assets/Scene/GameScene.fire');
|
|
const map = scene.find(o => o.Block_Prop);
|
|
assert.equal(map.Block_Prop[24].__uuid__, json('assets/prefab/prop/rainbow.prefab.meta').uuid);
|
|
assert.ok(scene.some(o => o._name === 'rainbow'));
|
|
for (const file of ['assets/action_bundle/prefab/winStreak2.prefab', 'assets/win/prefab/Win.prefab']) {
|
|
const nodes = json(file); assert.ok(nodes.some(o => o._name === 'progress'), file);
|
|
assert.ok(nodes.some(o => o.__type__ === 'cc.Sprite' && o._type === 3), file);
|
|
}
|
|
assert.ok(json('assets/win/prefab/Win.prefab').some(o => o._name === 'WinStreak2'));
|
|
});
|
|
|
|
function geometry(shape) {
|
|
const data = json(`assets/resources/prefab/block/block${shape}.prefab`);
|
|
const b = block(shape);
|
|
const rootNode = data.find(o => o.__type__ === 'cc.Node');
|
|
function copyNode(raw) {
|
|
const node = new Node(raw._name);
|
|
node.setContentSize(raw._contentSize); node.setAnchorPoint(raw._anchorPoint);
|
|
node.active = raw._active;
|
|
if (raw._trs) node.setPosition(raw._trs.array[0], raw._trs.array[1]);
|
|
for (const reference of raw._components || []) {
|
|
const c = data[reference.__id__];
|
|
if (c.__type__ === 'cc.Sprite') node.addComponent(Sprite);
|
|
else if (c.__type__ === 'cc.PolygonCollider') node.components.PolygonCollider = { points: c.points.map(p => v2(p.x, p.y)), offset: v2((c._offset || c.offset || {}).x, (c._offset || c.offset || {}).y) };
|
|
else if (c.data_string !== undefined) node.components.lq_collide = { data_string: c.data_string };
|
|
else if (c.heng !== undefined) { b.heng = c.heng; b.shu = c.shu; }
|
|
}
|
|
for (const child of raw._children || []) node.addChild(copyNode(data[child.__id__]));
|
|
return node;
|
|
}
|
|
b.node = copyNode(rootNode); b.node.components.Block = b;
|
|
return b;
|
|
}
|
|
function cloneNode(source) {
|
|
const node = new Node(source.name); node.setPosition(source.position);
|
|
node.setContentSize(source.getContentSize()); node.setAnchorPoint(source.getAnchorPoint()); node.active = source.active;
|
|
for (const name of Object.keys(source.components)) node.components[name] = { ...source.components[name] };
|
|
source.children.forEach(child => node.addChild(cloneNode(child)));
|
|
return node;
|
|
}
|
|
function cutFixture(shape, type = 0) {
|
|
const map = { node: new Node('Map'), blocks: [], Block_Array: Array.from({length: 23}, (_, prefabShape) => ({ prefabShape })) };
|
|
map.mapBlocksWall = Array.from({length: 15}, (_, x) => Array.from({length: 15}, (_, y) => {
|
|
const cell = new Node('cell'); cell.setPosition(x * 120, y * 120);
|
|
cell.components.MapBlock = { block_Id: '', nowColor: -1 };
|
|
return cell;
|
|
}));
|
|
let effects = 0;
|
|
map.special_Treatment = map.shrinkVine = map.checkButton = () => { effects++; };
|
|
const config = Array.from({length: 23}, () => Object.fromEntries([1,2,3,4,5,6].map(n => ['pos'+n, {x:0,y:0}])));
|
|
const cc = { ...engine, fx: { GameConfig: { PROP_INFO: config } }, instantiate(source) {
|
|
if (source.prefabShape === undefined) return cloneNode(source);
|
|
const b = geometry(source.prefabShape);
|
|
b.init = function(info) {
|
|
assert.deepEqual(Object.keys(info).sort(), ['block','color','id','position','type']);
|
|
Object.assign(this, { block_Info: info, color: info.color, type: info.type });
|
|
initBlocks.call(this); this.posX = (this.node.x - 65) / 120; this.posY = (this.node.y + 60) / 120;
|
|
this.setMapBlock = place; place.call(this);
|
|
};
|
|
return b.node;
|
|
} };
|
|
const cut = load('assets/Script/RainbowCut.ts', cc, {
|
|
'./RainbowRules': rules,
|
|
'./module/Tool/BlockAssetManager': { default: { instance: { getFrame: frame => frame } } },
|
|
'./module/Tool/BlockTexturePaths': { blockTexturePath: (color, shape) => color + 'color' + shape }
|
|
});
|
|
function place() { this.allBlocks.forEach(p => Object.assign(map.mapBlocksWall[this.posX+p.x][this.posY+p.y].components.MapBlock,
|
|
{block_Id:this.node.uuid, nowColor:this.color})); }
|
|
const b = geometry(shape); b.type = type; b.block_Info.type = type;
|
|
b.node.setPosition(5*120+65, 5*120-60); map.node.addChild(b.node);
|
|
b.setMapBlock = place; place.call(b); map.blocks.push(b.node);
|
|
const hit = new Node('hit'); hit.addComponent(Sprite); b.node.addChild(hit); b.hit = hit;
|
|
return { map, b, cut, effects: () => effects };
|
|
}
|
|
|
|
test('real prefab geometry cuts keep node identity, connected occupancy and colliders before hit', () => {
|
|
let cases = 0;
|
|
for (let shape = 1; shape < 23; shape++) for (const choice of rules.rainbowCuts(rules.RAINBOW_SHAPES[shape])) {
|
|
const f = cutFixture(shape); const node = f.b.node;
|
|
const before = pointsKey(f.b.allBlocks.map(p => ({x:p.x+5,y:p.y+5})));
|
|
const rainbow = f.cut.applyRainbowCut(f.map, f.b, choice);
|
|
assert.equal(f.b.node, node); assert.equal(f.b.block_Info.block, choice.shape);
|
|
const after = f.map.blocks.flatMap(n => n.components.Block.allBlocks.map(p => ({x:n.components.Block.posX+p.x,y:n.components.Block.posY+p.y})));
|
|
assert.equal(pointsKey(after), before);
|
|
for (const n of f.map.blocks) n.components.Block.allBlocks.forEach(p => {
|
|
const b = n.components.Block;
|
|
assert.equal(f.map.mapBlocksWall[b.posX+p.x][b.posY+p.y].components.MapBlock.block_Id, n.uuid);
|
|
});
|
|
const sample = geometry(choice.shape);
|
|
assert.deepEqual(plain(node.components.PolygonCollider), plain(sample.node.components.PolygonCollider));
|
|
for (const child of node.children.filter(c => c.getComponent('lq_collide'))) assert.ok(node.children.indexOf(child) < node.children.indexOf(f.b.hit));
|
|
assert.equal(rainbow.components.Block.color, 100); assert.equal(rainbow.components.Block.type, 0);
|
|
assert.equal(f.effects(), 0); cases++;
|
|
}
|
|
assert.ok(cases > 40);
|
|
});
|
|
|
|
test('stack cuts both layers at the same cell, preserves relationships and yields exactly one rainbow', () => {
|
|
const f = cutFixture(1, 1); const upper = geometry(1); upper.type = 10;
|
|
upper.node.scaleX = upper.node.scaleY = .7; f.map.node.addChild(upper.node); f.map.blocks.push(upper.node);
|
|
f.b.block_Info.node = upper.node; upper.block_Info.node = f.b.node;
|
|
f.cut.applyRainbowCut(f.map, f.b, rules.rainbowCuts(f.b.allBlocks)[0]);
|
|
assert.equal(f.map.blocks.length, 3);
|
|
assert.equal(f.b.allBlocks.length + upper.allBlocks.length + 1, 3);
|
|
assert.equal(f.b.block_Info.node, upper.node); assert.equal(upper.block_Info.node, f.b.node);
|
|
assert.equal(upper.node.scaleX, .7); assert.equal(upper.type, 10); assert.equal(f.b.type, 1);
|
|
assert.equal(f.b.posX, upper.posX); assert.equal(f.b.posY, upper.posY);
|
|
assert.equal(f.effects(), 0);
|
|
});
|
|
|
|
test('cut retains floor and bomb component state and removes only the cut cell floor without effects', () => {
|
|
const f = cutFixture(3, 17); f.b.block_Info.floor = true;
|
|
const floors = f.b.allBlocks.map(p => {
|
|
const node = new Node('floor'); node.setPosition(-65+p.x*120,60+p.y*120);
|
|
node.components.Floor = {time:7, color:3, posX:5+p.x, posY:5+p.y}; f.b.node.addChild(node); return node;
|
|
});
|
|
const boom = new Node('boom2'); boom.components.Boom = {time:4, over:false}; f.b.node.addChild(boom);
|
|
const state = boom.components.Boom;
|
|
const choice = rules.rainbowCuts(f.b.allBlocks)[0];
|
|
f.cut.applyRainbowCut(f.map, f.b, choice);
|
|
assert.equal(boom.components.Boom, state); assert.equal(state.time, 4); assert.equal(f.b.type, 17);
|
|
assert.equal(f.b.block_Info.floor, true);
|
|
assert.equal(floors.filter(n => n.destroyed).length, 1);
|
|
floors.filter(n => !n.destroyed).forEach(n => {
|
|
assert.equal(n.components.Floor.time, 7); assert.equal(n.components.Floor.color, 3);
|
|
assert.equal(n.components.Floor.posX, f.b.posX + Math.round((n.x+65)/120));
|
|
});
|
|
assert.equal(f.effects(), 0);
|
|
});
|
|
|
|
test('rainbow elimination advances numeric counters and vines, never switches or count bombs', () => {
|
|
const f = cutFixture(1); const removed = block(0).node;
|
|
const amounts = {};
|
|
for (const name of ['Freeze','Question','Floor','Lock','Heng','Shu','Boom','Switchs']) {
|
|
const node = new Node(name); node.components[name] = { time:3, color:8, reduce(...args) { amounts[name]=args; } }; f.b.node.addChild(node);
|
|
}
|
|
let vines=0, walls=0;
|
|
f.map.blocks.push(removed); f.map.shrinkVine=()=>vines++;
|
|
f.map.changeFreeze=f.map.changeLock=f.map.changeAdhesive=()=>walls++;
|
|
f.cut.applyRainbowElimination(f.map, removed);
|
|
assert.equal(vines,1); assert.equal(walls,3); assert.equal(f.map.blocks.includes(removed),false);
|
|
assert.deepEqual(plain(amounts), {Freeze:[1,true],Question:[1,true],Floor:[1,8],Lock:[],Heng:[],Shu:[]});
|
|
const changeBoom=method(mapFile,'changeBoom'); changeBoom.call({}, {getComponent:()=>({color:100})});
|
|
});
|
|
|
|
test('only layer_two 2 replaces the reward, with original streak/mainline/unlock eligibility', () => {
|
|
const file = 'assets/Script/module/Config/GameConfig.ts';
|
|
const config = { isRainbowWinStreak: method(file,'isRainbowWinStreak'), isWinStreakUnlocked: () => true };
|
|
const active = method(file,'isRainbowRewardActive');
|
|
for (const group of [undefined, 1, 2, 3]) {
|
|
config.GM_INFO = { abTests: {layer_two:group}, winStreak:10, otherLevel:0 };
|
|
assert.equal(active.call(config), group === 2);
|
|
}
|
|
config.GM_INFO.abTests.layer_two = 2;
|
|
config.GM_INFO.winStreak = 9; assert.equal(active.call(config),false);
|
|
config.GM_INFO.winStreak = 10; config.GM_INFO.otherLevel = 5; assert.equal(active.call(config),false);
|
|
config.GM_INFO.otherLevel = 0; config.isWinStreakUnlocked=()=>false; assert.equal(active.call(config),false);
|
|
});
|
|
|
|
test('normal casts wait for release animation; a prior partial hammer hit does not exclude the remaining block', () => {
|
|
const f = controllerFixture(); f.controller.first = false;
|
|
f.blocks[0].isEliminatedByHammer = true; // Existing field also stays true after a partial lock hit.
|
|
assert.equal(f.controller.candidates().length,2);
|
|
const exit = block().node; exit.components.Block.over = true; f.map.node.children.push(exit);
|
|
f.controller.update(.1); assert.equal(f.controller.casting,false);
|
|
exit.active = false; f.controller.update(.1); assert.equal(f.controller.casting,true);
|
|
f.map.pause = true; f.controller.update(10); assert.equal(f.applied.length,0);
|
|
f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length,1);
|
|
});
|
|
|
|
test('browser startup has ten wins before entering GameScene; disabling test mode restores normal defaults', () => {
|
|
for (const enabled of [true, false]) {
|
|
const config = { forceRainbowWinStreakB: enabled };
|
|
const cc = { resources: { load() {} }, fx: { GameConfig: config, GameTool: { getHealth() {} } } };
|
|
method('assets/Script/module/Config/GameConfig.ts', 'GM_INFO_init', { cc }).call(config);
|
|
const manager = {};
|
|
// No wx/tt globals: exercise the browser's actual startup branch.
|
|
method('assets/Script/GameManager.ts', 'readUserData', { cc }).call(manager);
|
|
assert.equal(manager.load3, true);
|
|
assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0);
|
|
assert.equal(config.GM_INFO.winState, enabled);
|
|
}
|
|
});
|
|
|
|
test('home restores test streak before rendering, even when login or a previous loss supplied zero', () => {
|
|
for (const enabled of [true, false]) {
|
|
const config = { forceRainbowWinStreakB: enabled, GM_INFO: { winStreak: 0, winState: false } };
|
|
const beforeRendering = new Error('UI boundary');
|
|
const onLoad = method('assets/Script/JiaZai.ts', 'onLoad', { cc: { fx: { GameConfig: config } } });
|
|
assert.throws(() => onLoad.call({ ensureLoadingOnTop() { throw beforeRendering; } }), error => error === beforeRendering);
|
|
assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0);
|
|
assert.equal(config.GM_INFO.winState, enabled);
|
|
}
|
|
});
|