335 lines
18 KiB
JavaScript
335 lines
18 KiB
JavaScript
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');
|
|
function method(name, globals = {}) {
|
|
const source = ts.createSourceFile('Map.ts', read('assets/Script/Map.ts'), 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;
|
|
return vm.runInNewContext(code, { console, ...globals }).prototype[name];
|
|
}
|
|
const mod = { exports: {} };
|
|
vm.runInNewContext(ts.transpileModule(read('assets/Script/RainbowOldRules.ts'),
|
|
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText,
|
|
{ exports: mod.exports, module: mod });
|
|
const rules = mod.exports;
|
|
|
|
test('runtime pools agree with the static scan for all 2030 level configurations', () => {
|
|
const audit = JSON.parse(read('docs/rainbow-old-candidate-scan.json'));
|
|
const empty = [];
|
|
for (const row of audit.levels) {
|
|
const data = JSON.parse(read(row.source));
|
|
const counts = [0, 0, 0];
|
|
for (const info of data.BLOCK_INFO[0]) {
|
|
const pool = rules.oldRainbowPool(info, row.buttonColors);
|
|
if (pool) counts[pool - 1]++;
|
|
}
|
|
assert.deepEqual(counts, row.poolCounts, row.source);
|
|
if (!counts.some(Boolean)) empty.push(`${row.folder}/${row.level}`);
|
|
}
|
|
assert.equal(audit.levels.length, 2030);
|
|
assert.deepEqual(empty, ['Json/735', 'Json/911', 'Json/929', 'Json/973', 'Json/1103', 'Json/1119',
|
|
'Json/1608', 'Json/1646', 'Json/1740', 'Json/1898', 'Json/1915', 'Json/1922', 'Json/1950', 'Json2/24']);
|
|
});
|
|
|
|
test('whole-block selection uses the first non-empty pool with equal per-block weights', () => {
|
|
const candidates = [{ pool: 2, cells: 2 }, { pool: 1, cells: 2 }, { pool: 3, cells: 5 }, { pool: 1, cells: 5 }];
|
|
assert.equal(rules.chooseOldRainbowTarget(candidates, () => 0.49), candidates[1]);
|
|
assert.equal(rules.chooseOldRainbowTarget(candidates, () => 0.5), candidates[3]);
|
|
assert.equal(rules.chooseOldRainbowTarget(candidates.filter(x => x.pool !== 1)), candidates[0]);
|
|
assert.equal(rules.chooseOldRainbowTarget([{ pool: 0 }]), null);
|
|
assert.equal(rules.oldRainbowPool({ type: 4, block: 1, color: 5, flowerSend: 3 }), 3);
|
|
assert.equal(rules.oldRainbowPool({ type: 4, block: 1, color: 5, floor: 1 }), 0);
|
|
assert.equal(rules.oldRainbowPool({ type: 0, block: 1, color: 5, lock: true }), 2);
|
|
assert.equal(rules.oldRainbowPool({ type: 0, block: 1, color: 5 }, [5]), 0);
|
|
});
|
|
|
|
test('1x1 blocks are excluded before all three pools, including allowed mechanisms and attachments', () => {
|
|
for (const type of [0, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 17, 21]) {
|
|
for (const extra of [{}, { lock: true, flowerSend: 3, horizontal: true }]) {
|
|
assert.equal(rules.oldRainbowPool({ block: 0, color: 5, type, ...extra }), 0, `single-cell type ${type}`);
|
|
assert.ok(rules.oldRainbowPool({ block: 1, color: 5, type, ...extra }) > 0, `two-cell type ${type}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
function entryFixture(infos, fail = false) {
|
|
let applications = 0;
|
|
let readyCalls = 0;
|
|
const instantiated = [];
|
|
const openings = [];
|
|
const errors = [];
|
|
const entryEvents = [];
|
|
const bottle = { active: false };
|
|
const bottleBg = { active: false };
|
|
const bottom = { getChildByName: name => ({ rainbow: bottle, rainbowBg: bottleBg })[name] };
|
|
const transition = { active: false };
|
|
const flightParent = { convertToNodeSpaceAR: point => ({ x: point.x - 10, y: point.y - 10 }) };
|
|
const blocks = infos.map(info => {
|
|
const icon = { active: true, anchorX: 0, anchorY: 0.5, width: 240, height: 120,
|
|
convertToWorldSpaceAR: point => ({ x: point.x + 100, y: point.y + 200 }) };
|
|
const block = { block_Info: info, type: info.type, color: info.color, oldRainbow: false,
|
|
node: { addChild(child) { this.child = child; }, getChildByName() { return icon; } } };
|
|
return { active: true, getComponent() { return block; } };
|
|
});
|
|
// The actual flight controller has its own timing/background tests. This seam
|
|
// lets the map receive landing and tail completion on distinct updates.
|
|
class Opening {
|
|
constructor(parent, source, to, onLand, backgroundSource) {
|
|
assert.equal(parent, flightParent);
|
|
assert.equal(source, bottle);
|
|
assert.equal(backgroundSource, bottleBg);
|
|
assert.equal(source.active, false, 'the bottom bottle is a hidden template');
|
|
assert.equal(backgroundSource.active, false, 'the bottom background is a hidden template');
|
|
assert.deepEqual({ x: to.x, y: to.y }, { x: 210, y: 190 });
|
|
this.onLand = onLand;
|
|
this.updates = [];
|
|
this.animatedBottle = { active: true };
|
|
openings.push(this);
|
|
}
|
|
update(dt, paused) {
|
|
this.updates.push({ dt, paused });
|
|
if (paused) return false;
|
|
if (this.landNext) {
|
|
this.landNext = false;
|
|
this.onLand();
|
|
}
|
|
return !!this.tailFinished;
|
|
}
|
|
dispose() { this.disposed = true; this.animatedBottle.active = false; }
|
|
}
|
|
const cc = { isValid: () => true, v2: (x, y) => ({ x, y }),
|
|
fx: { GameConfig: { showBlockLoadError(error, retry) { errors.push({ error, retry }); } } },
|
|
instantiate(prefab) {
|
|
assert.equal(prefab, 'user-prefab-25');
|
|
const child = { active: true, removeFromParent() {}, destroy() { this.destroyed = true; },
|
|
getComponent() { return { apply(block) {
|
|
assert.equal(child.active, false, 'configure geometry before activation');
|
|
applications++;
|
|
if (fail) throw Error('missing shape frame');
|
|
block.node.getChildByName('icon').active = false;
|
|
} }; } };
|
|
instantiated.push(child);
|
|
return child;
|
|
} };
|
|
const globals = { cc, ...rules, RainbowOldVisual: {}, RainbowOldOpening: Opening };
|
|
const apply = method('applyOldRainbowAtStart', globals);
|
|
const blocker = { removeFromParent() { this.removed = true; }, destroy() { this.destroyed = true; } };
|
|
const map = { blocks, buttonBlock: [], oldRainbowPreparing: true, oldRainbowEntryBlocker: blocker,
|
|
node: { parent: { getChildByName: name => name === 'Bottom' ? bottom : null,
|
|
parent: { parent: { getChildByName: name => name === 'zhuanchang' ? transition : null } } } },
|
|
magics: { parent: flightParent }, oldRainbowOpening: null,
|
|
oldRainbowEntryGuidesPending: true, oldRainbowIntroPending: true,
|
|
showMapEntryGuides() { entryEvents.push('guides'); },
|
|
showEnterNewModeIfNeeded() { entryEvents.push('intro'); },
|
|
finishOldRainbowOpening: method('finishOldRainbowOpening', globals),
|
|
updateOldRainbowOpening: method('updateOldRainbowOpening', globals),
|
|
Block_Prop: Array(25).fill(null).concat('user-prefab-25') };
|
|
map.oldRainbowEntryReady = () => { readyCalls++; apply.call(map); };
|
|
return { map, apply, blocker, instantiated, openings, errors, entryEvents, bottle, bottleBg, transition,
|
|
applications: () => applications, readyCalls: () => readyCalls, setFail: value => { fail = value; } };
|
|
}
|
|
|
|
test('entry converts only on landing and keeps input locked until the tail ends; revive does not recast', () => {
|
|
const infos = [{ block: 18, type: 0, color: 5 }, { block: 0, type: 2, color: 2, flowerSend: 4 }];
|
|
const f = entryFixture(infos);
|
|
const before = JSON.stringify(infos);
|
|
f.apply.call(f.map);
|
|
const opening = f.openings[0];
|
|
assert.equal(f.applications(), 0, 'flight begins before any conversion');
|
|
assert.equal(f.instantiated.length, 0, 'no rainbow overlay exists before landing');
|
|
assert.equal(f.map.oldRainbowPreparing, true);
|
|
assert.equal(opening.animatedBottle.active, true);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
f.apply.call(f.map);
|
|
assert.equal(f.openings.length, 1, 'repeated readiness callbacks cannot start another flight');
|
|
f.map.updateOldRainbowOpening(0.1);
|
|
assert.equal(f.applications(), 0);
|
|
opening.landNext = true;
|
|
f.map.updateOldRainbowOpening(0.1);
|
|
assert.equal(f.applications(), 1);
|
|
assert.equal(f.map.blocks[0].getComponent().oldRainbow, true);
|
|
assert.equal(f.map.blocks[1].getComponent().oldRainbow, false);
|
|
assert.equal(JSON.stringify(infos), before);
|
|
assert.equal(f.map.blocks.length, 2, 'no split and no extra block');
|
|
assert.equal(f.map.oldRainbowPreparing, true, 'landing is not the end of the opening');
|
|
assert.equal(f.blocker.destroyed, undefined);
|
|
assert.equal(opening.animatedBottle.active, true);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
assert.deepEqual(f.entryEvents, [], 'guides remain deferred during the tail');
|
|
opening.tailFinished = true;
|
|
f.map.updateOldRainbowOpening(0.1);
|
|
assert.equal(f.map.oldRainbowPreparing, false);
|
|
assert.equal(f.blocker.destroyed, true);
|
|
assert.equal(opening.disposed, true);
|
|
assert.equal(opening.animatedBottle.active, false);
|
|
assert.equal(f.map.oldRainbowOpening, null);
|
|
assert.equal(f.map.oldRainbowEntryReady, null);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
assert.deepEqual(f.entryEvents, ['guides', 'intro']);
|
|
assert.equal(f.readyCalls(), 1, 'deferred map readiness runs only after tail completion');
|
|
f.apply.call(f.map);
|
|
f.map.updateOldRainbowOpening(1);
|
|
assert.equal(f.applications(), 1, 'the same map cannot grant a second reward');
|
|
assert.equal(f.openings.length, 1);
|
|
});
|
|
|
|
test('empty opening pool consumes its chance without flight and leaves both bottom templates hidden', () => {
|
|
const f = entryFixture([{ block: 1, type: 20, color: 5 }]);
|
|
f.apply.call(f.map);
|
|
assert.equal(f.applications(), 0);
|
|
assert.equal(f.openings.length, 0);
|
|
assert.equal(f.map.oldRainbowPreparing, false);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
assert.equal(f.blocker.destroyed, true);
|
|
const b = f.map.blocks[0].getComponent();
|
|
b.type = b.block_Info.type = 0;
|
|
f.apply.call(f.map);
|
|
assert.equal(f.applications(), 0, 'a later candidate must not grant the skipped reward');
|
|
});
|
|
|
|
test('opening ignores 1x1 ordinary and mechanism blocks when falling back through any pool', () => {
|
|
for (const type of [0, 2, 4]) {
|
|
const f = entryFixture([{ block: 0, type: 0, color: 5 }, { block: 0, type: 2, color: 5 },
|
|
{ block: 0, type: 4, color: 5 }, { block: 1, type, color: 5 }]);
|
|
f.apply.call(f.map);
|
|
f.openings[0].landNext = true;
|
|
f.map.updateOldRainbowOpening(1);
|
|
assert.deepEqual(f.map.blocks.map(node => node.getComponent().oldRainbow), [false, false, false, true]);
|
|
}
|
|
});
|
|
|
|
test('an all-1x1 board skips the opening, releases input and never grants later', () => {
|
|
const f = entryFixture([0, 2, 4].map(type => ({ block: 0, type, color: 5 })));
|
|
f.apply.call(f.map);
|
|
assert.equal(f.openings.length, 0);
|
|
assert.equal(f.applications(), 0);
|
|
assert.equal(f.map.oldRainbowPreparing, false);
|
|
assert.equal(f.blocker.destroyed, true);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
f.map.blocks[0].getComponent().block_Info.block = 1;
|
|
f.apply.call(f.map);
|
|
assert.equal(f.openings.length, 0);
|
|
});
|
|
|
|
test('the map forwards transition pause and cannot convert or unlock behind the transition', () => {
|
|
const f = entryFixture([{ block: 1, type: 0, color: 5 }]);
|
|
f.apply.call(f.map);
|
|
const opening = f.openings[0];
|
|
opening.landNext = true;
|
|
f.transition.active = true;
|
|
f.map.updateOldRainbowOpening(2);
|
|
assert.deepEqual(opening.updates, [{ dt: 2, paused: true }]);
|
|
assert.equal(f.applications(), 0);
|
|
assert.equal(f.map.oldRainbowPreparing, true);
|
|
assert.equal(f.bottle.active, false);
|
|
assert.equal(f.bottleBg.active, false);
|
|
f.transition.active = false;
|
|
f.map.updateOldRainbowOpening(0.1);
|
|
assert.equal(opening.updates[1].paused, false);
|
|
assert.equal(f.applications(), 1);
|
|
assert.equal(f.map.oldRainbowPreparing, true);
|
|
});
|
|
|
|
test('landing errors dispose the flight, retain input lock and retry without granting twice', () => {
|
|
const broken = entryFixture([{ block: 1, type: 0, color: 5 }], true);
|
|
broken.apply.call(broken.map);
|
|
const first = broken.openings[0];
|
|
first.landNext = true;
|
|
broken.map.updateOldRainbowOpening(1);
|
|
assert.equal(first.disposed, true);
|
|
assert.equal(first.animatedBottle.active, false);
|
|
assert.equal(broken.bottle.active, false);
|
|
assert.equal(broken.bottleBg.active, false);
|
|
assert.equal(broken.map.oldRainbowOpening, null);
|
|
assert.equal(broken.instantiated[0].destroyed, true);
|
|
assert.match(broken.errors[0].error.message, /missing shape frame/);
|
|
assert.equal(broken.errors[0].retry, broken.map.oldRainbowEntryReady);
|
|
assert.equal(broken.map.oldRainbowPreparing, true);
|
|
assert.equal(broken.blocker.destroyed, undefined);
|
|
assert.equal(broken.map.blocks[0].getComponent().oldRainbow, false);
|
|
assert.equal(broken.map.blocks[0].getComponent().node.getChildByName('icon').active, true);
|
|
assert.deepEqual(broken.entryEvents, []);
|
|
broken.setFail(false);
|
|
broken.errors[0].retry();
|
|
const second = broken.openings[1];
|
|
assert.equal(second.animatedBottle.active, true);
|
|
assert.equal(broken.bottle.active, false);
|
|
assert.equal(broken.bottleBg.active, false);
|
|
second.landNext = true;
|
|
broken.map.updateOldRainbowOpening(1);
|
|
assert.equal(broken.map.blocks[0].getComponent().oldRainbow, true);
|
|
assert.equal(broken.map.oldRainbowPreparing, true);
|
|
second.tailFinished = true;
|
|
broken.map.updateOldRainbowOpening(0.1);
|
|
assert.equal(broken.map.oldRainbowPreparing, false);
|
|
assert.equal(second.animatedBottle.active, false);
|
|
assert.equal(broken.openings.length, 2);
|
|
assert.equal(broken.instantiated.filter(node => !node.destroyed).length, 1);
|
|
});
|
|
|
|
function gate(overrides = {}, length = false) {
|
|
const wall = { wall_Info: { length: 1 }, color: 2, colorArray: [], special: 0, open: true,
|
|
toggleOpen: true, ...overrides };
|
|
return { getComponent: () => wall, parent: { getChildByName: () => ({ active: length }) } };
|
|
}
|
|
|
|
test('old rainbow crosses other colors while both original-color depletion and traversed gate changes run once', () => {
|
|
const events = [];
|
|
const block = { oldRainbow: true, color: 5, type: 0 };
|
|
const node = { getComponent: () => block };
|
|
const pass = method('passWall');
|
|
const map = { checkColor(color, prop, passed) { events.push([color, prop, passed === node]); } };
|
|
const wall = gate({ colorArray: [2, 3], changeColorWall() { events.push('door'); } });
|
|
assert.equal(pass.call(map, true, [wall], node), true);
|
|
assert.deepEqual(events, [[5, false, true], 'door']);
|
|
assert.equal(block.color, 5);
|
|
for (const state of [{ wall_Info: null }, { wall_Info: { stickLength: 2 } }, { special: 2, open: false },
|
|
{ special: 3 }, { special: 6 }, { toggleOpen: false }, { jump: true }]) {
|
|
assert.equal(pass.call(map, true, [gate(state)], node), false);
|
|
}
|
|
assert.equal(pass.call(map, true, [gate({}, true)], node), false);
|
|
block.type = 5;
|
|
assert.equal(pass.call(map, true, [gate()], node), false, 'stars still require star gates');
|
|
});
|
|
|
|
test('old rainbow uses ordinary elimination events including keys, floor real color, freezing and switches', () => {
|
|
for (const prop of [false, true]) {
|
|
const events = [];
|
|
const create = (type, info = {}, components = {}) => {
|
|
const block = { type, block_Info: info };
|
|
const children = Object.entries(components).map(([name, component]) => ({ name, getComponent: () => component }));
|
|
return { getComponent: () => block, children, getChildByName: name => children.find(child => child.name === name) };
|
|
};
|
|
const key = create(2); Object.assign(key.getComponent(), { color: 5, oldRainbow: true });
|
|
const nodes = [key,
|
|
create(4, {}, { freeze: { reduce: (...args) => events.push(['ice', ...args]) } }),
|
|
create(3, {}, { lock: { reduce: () => events.push('key') } }),
|
|
create(0, { floor: 1 }, { floor: { reduce: (...args) => events.push(['floor', ...args]) } }),
|
|
create(0, { lock: false }, { switchs: { change: () => events.push('switch') } })];
|
|
const map = { node: { children: nodes }, blocks: nodes.slice(), barrierBlock: [], isSpawnLockedNode: () => false,
|
|
shrinkVine() {}, changeFreeze() {}, changeState() {}, changeRevolvingWall() {}, changeJumpWall() {},
|
|
changeLongAndShortWall() {}, changeStickWall() {}, changeRevolvingColorWall() {} };
|
|
method('special_Treatment', { applyRainbowElimination() { throw Error('C entered B elimination'); } }).call(map, key, true, prop);
|
|
assert.deepEqual(events, [['ice', 1, prop], ['floor', 1, 5], 'switch', 'key']);
|
|
assert.equal(map.blocks.includes(key), false);
|
|
}
|
|
});
|
|
|
|
test('countdown and initial touch state cannot start while C conversion is pending', () => {
|
|
method('startUpdate').call({ oldRainbowPreparing: true, onBlockClick() { throw Error('started'); } });
|
|
method('startTimeCutDown').call({ oldRainbowPreparing: true, stopTimeCutDown() { throw Error('started'); } });
|
|
});
|