Add support for headless bulk testing

This commit is contained in:
Eevee (Evelyn Woods) committed 2021-03-06 12:39:16 -07:00
1 parent dac868edbf
commit 1f2a58d21c
12 files changed
+610 -177

No files matched your search

+156
View File
@@ -114,3 +114,159 @@ export const PICKUP_PRIORITIES = {
player: 1, // players and doppelgangers; red keys (ignored by everything else)
real_player: 0,
};
export const COMPAT_RULESET_LABELS = {
lexy: "Lexy",
steam: "Steam/CC2",
'steam-strict': "Steam/CC2 (strict)",
lynx: "Lynx",
ms: "Microsoft",
custom: "Custom",
};
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
// FIXME some of the names of the flags themselves kinda suck
export const COMPAT_FLAGS = [
// Level loading
{
key: 'no_auto_convert_ccl_popwalls',
label: "Recessed walls under actors in CCL levels are left alone",
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
}, {
key: 'no_auto_convert_ccl_blue_walls',
label: "Blue walls under blocks in CCL levels are left alone",
rulesets: new Set(['steam-strict', 'lynx']),
},
// Core
{
key: 'use_lynx_loop',
label: "Game uses the Lynx-style update loop",
rulesets: new Set(['steam', 'steam-strict', 'lynx', 'ms']),
}, {
key: 'player_moves_last',
label: "Player always moves last",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'emulate_60fps',
label: "Game runs at 60 FPS",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'reuse_actor_slots',
label: "Game reuses slots in the actor list",
rulesets: new Set(['lynx']),
}, {
key: 'force_lynx_animation_lengths',
label: "Animations use Lynx duration",
rulesets: new Set(['lynx']),
},
// Tiles
{
// XXX this is goofy
key: 'tiles_react_instantly',
label: "Tiles react when approached",
rulesets: new Set(['ms']),
}, {
key: 'rff_actually_random',
label: "Random force floors are actually random",
rulesets: new Set(['ms']),
}, {
key: 'no_backwards_override',
label: "Player can't override backwards on a force floor",
rulesets: new Set(['lynx']),
}, {
key: 'traps_like_lynx',
label: "Traps eject faster, and even when already open",
rulesets: new Set(['lynx']),
},
// Items
{
key: 'no_immediate_detonate_bombs',
label: "Mines under non-player actors don't explode at level start",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'detonate_bombs_under_players',
label: "Mines under players explode at level start",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys",
rulesets: new Set(['ms']),
}, {
key: 'monsters_blocked_by_items',
label: "Monsters can't step on items to get the player",
rulesets: new Set(['lynx']),
},
// Blocks
{
key: 'no_early_push',
label: "Player pushes blocks at move time",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'use_legacy_hooking',
label: "Pulling blocks with the hook happens at decision time",
rulesets: new Set(['steam', 'steam-strict']),
}, {
// FIXME this is kind of annoying, there are some collision rules too
key: 'tanks_teeth_push_ice_blocks',
label: "Ice blocks emulate pgchip rules",
rulesets: new Set(['ms']),
}, {
key: 'emulate_spring_mining',
label: "Spring mining is possible",
rulesets: new Set(['steam-strict']),
/* XXX not implemented
}, {
key: 'emulate_flicking',
label: "Flicking is possible",
rulesets: new Set(['ms']),
*/
},
// Monsters
{
// TODO? in lynx they ignore the button while in motion too
// TODO what about in a trap, in every game??
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
// TODO yellow tanks seem to have memory too??
key: 'tanks_always_obey_button',
label: "Blue tanks always obey blue buttons",
rulesets: new Set(['steam-strict']),
}, {
key: 'tanks_ignore_button_while_moving',
label: "Blue tanks ignore blue buttons while moving",
rulesets: new Set(['lynx']),
}, {
key: 'blobs_use_tw_prng',
label: "Blobs use the Tile World RNG",
rulesets: new Set(['lynx']),
}, {
key: 'teeth_target_internal_position',
label: "Teeth target the player's internal position",
rulesets: new Set(['lynx']),
}, {
key: 'rff_blocks_monsters',
label: "Random force floors block monsters",
rulesets: new Set(['ms']),
}, {
key: 'bonking_isnt_instant',
label: "Bonking while sliding doesn't apply instantly",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'fire_allows_monsters',
label: "Fire doesn't block monsters",
rulesets: new Set(['ms']),
},
];
export function compat_flags_for_ruleset(ruleset) {
let compat = {};
for (let compatdef of COMPAT_FLAGS) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
}
}
return compat;
}
+10 -3
View File
@@ -163,6 +163,10 @@ export class StoredPack {
// error: any error received while loading the level
// bytes: Uint8Array of the encoded level data
this.level_metadata = [];
// Sparse/optional array of Replays, generally from an ancillary file like a TWS
// TODO unclear if this is a good API for this
this.level_replays = [];
}
// TODO this may or may not work sensibly when correctly following a c2g
@@ -177,10 +181,13 @@ export class StoredPack {
// The editor stores inflated levels at times, so respect that
return meta.stored_level;
}
else {
// Otherwise, attempt to load the level
return this._level_loader(meta);
// Otherwise, attempt to load the level
let stored_level = this._level_loader(meta);
if (! stored_level.has_replay && this.level_replays[index]) {
stored_level._replay = this.level_replays[index];
}
return stored_level;
}
}
+8 -2
View File
@@ -409,8 +409,14 @@ const TILE_ENCODING = {
},
0x44: {
name: 'cloner',
// TODO visual directions bitmask, no gameplay impact, possible editor impact
modifier: null,
modifier: {
decode(tile, mod) {
tile.arrows = mod;
},
encode(tile) {
return tile.arrows;
},
},
},
0x45: {
name: 'hint',
-1
View File
@@ -71,7 +71,6 @@ export function parse_solutions(bytes) {
let step_parity = initial_state >> 3;
let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7];
let initial_rng = view.getUint32(p + 8, true); // FIXME how is this four bytes?? lynx rng doesn't even have four bytes of STATE
console.log(number, initial_state.toString(16), initial_rng.toString(16));
let total_duration = view.getUint32(p + 12, true);
// TODO split this off though
+401
View File
@@ -0,0 +1,401 @@
import { compat_flags_for_ruleset } from '../defs.js';
import { Level } from '../game.js';
import * as format_c2g from '../format-c2g.js';
import * as format_dat from '../format-dat.js';
import * as format_tws from '../format-tws.js';
import * as util from '../util.js';
import { stdout } from 'process';
import { opendir, readFile } from 'fs/promises';
import { performance } from 'perf_hooks';
// TODO arguments:
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
// - filter existing packs
// - verbose: ?
// - quiet: hide failure reasons
// - support for xfails somehow?
// TODO use this for a test suite
export class LocalDirectorySource extends util.FileSource {
constructor(root) {
super();
this.root = root;
this.files = {};
this._loaded_promise = this._scan_dir('/');
}
async _scan_dir(path) {
let dir = await opendir(this.root + path);
for await (let dirent of dir) {
if (dirent.isDirectory()) {
await this._scan_dir(path + dirent.name + '/');
}
else {
let filepath = path + dirent.name;
this.files[filepath.toLowerCase()] = filepath;
if (this.files.size > 2000)
throw `way, way too many files in local directory source ${this.root}`;
}
}
}
async get(path) {
let realpath = this.files[path.toLowerCase()];
if (realpath) {
return (await readFile(this.root + realpath)).buffer;
}
else {
throw new Error(`No such file: ${path}`);
}
}
}
function pad(s, n) {
return s.substring(0, n).padEnd(n, " ");
}
const RESULT_TYPES = {
'no-replay': {
color: "\x1b[90m",
symbol: "-",
},
success: {
color: "\x1b[92m",
symbol: ".",
},
early: {
color: "\x1b[96m",
symbol: "?",
},
failure: {
color: "\x1b[91m",
symbol: "#",
},
'short': {
color: "\x1b[93m",
symbol: "#",
},
error: {
color: "\x1b[95m",
symbol: "X",
},
};
const ANSI_RESET = "\x1b[39m";
async function test_pack(pack, ruleset) {
let dummy_sfx = {
set_player_position() {},
play() {},
play_once() {},
};
let compat = compat_flags_for_ruleset(ruleset);
// TODO factor out the common parts maybe?
stdout.write(pad(`${pack.title} (${ruleset})`, 20) + " ");
let num_levels = pack.level_metadata.length;
let num_passed = 0;
let num_missing = 0;
let total_tics = 0;
let t0 = performance.now();
let last_pause = t0;
let failures = [];
for (let i = 0; i < num_levels; i++) {
let stored_level, level;
let level_start_time = performance.now();
let record_result = (token, short_status, include_canvas, comment) => {
let result_stuff = RESULT_TYPES[token];
stdout.write(result_stuff.color + result_stuff.symbol);
if (token === 'failure' || token === 'short') {
failures.push({
token,
short_status,
comment,
level,
stored_level,
index: i,
fail_reason: level ? level.fail_reason : null,
time_elapsed: performance.now() - level_start_time,
time_expected: stored_level ? stored_level.replay.duration / 20 : null,
title: stored_level.title ?? "[error]",
time_simulated: level.tic_counter / 20,
});
}
if (level) {
/*
mk('td.-clock', util.format_duration(level.tic_counter / TICS_PER_SECOND)),
mk('td.-delta', util.format_duration((level.tic_counter - stored_level.replay.duration) / TICS_PER_SECOND, 2)),
mk('td.-speed', ((level.tic_counter / TICS_PER_SECOND) / (performance.now() - level_start_time) * 1000).toFixed(2) + '×'),
*/
}
else {
}
// FIXME allegedly it's possible to get a canvas working in node...
/*
if (include_canvas && level) {
try {
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
this.renderer.set_tileset(tileset);
let canvas = mk('canvas', {
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
});
this.renderer.set_level(level);
this.renderer.set_active_player(level.player);
this.renderer.draw();
canvas.getContext('2d').drawImage(
this.renderer.canvas, 0, 0,
this.renderer.canvas.width, this.renderer.canvas.height);
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
}
catch (e) {
console.error(e);
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
`Internal error while trying to capture screenshot: ${e}`)));
}
}
*/
if (level) {
total_tics += level.tic_counter;
}
};
try {
stored_level = pack.load_level(i);
if (! stored_level.has_replay) {
record_result('no-replay', "No replay");
num_missing += 1;
continue;
}
// TODO? this.current_status.textContent = `Testing level ${i + 1}/${num_levels} ${stored_level.title}...`;
let replay = stored_level.replay;
level = new Level(stored_level, compat);
level.sfx = dummy_sfx;
level.undo_enabled = false; // slight performance boost
replay.configure_level(level);
while (true) {
let input = replay.get(level.tic_counter);
level.advance_tic(input);
if (level.state === 'success') {
if (level.tic_counter < replay.duration - 10) {
// Early exit is dubious (e.g. this happened sometimes before multiple
// players were implemented correctly)
record_result('early', "Won early", true);
}
else {
record_result('success', "Won");
}
num_passed += 1;
break;
}
else if (level.state === 'failure') {
record_result('failure', "Lost", true);
break;
}
else if (level.tic_counter >= replay.duration + 200) {
record_result('short', "Out of input", true);
break;
}
if (level.tic_counter % 20 === 1) {
// XXX
/*
if (handle.cancel) {
record_result('interrupted', "Interrupted");
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
return;
}
*/
// Don't run for more than 100ms at a time, to avoid janking the browser...
// TOO much. I mean, we still want it to reflow the stuff we've added, but
// we also want to be pretty aggressive so this finishes quickly
// XXX unnecessary headless
/*
let now = performance.now();
if (now - last_pause > 100) {
await util.sleep(4);
last_pause = now;
}
*/
}
}
}
catch (e) {
//console.error(e);
record_result(
'error', "Error", true,
`Replay failed due to internal error (see console for traceback): ${e}`);
}
}
let total_real_elapsed = (performance.now() - t0) / 1000;
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}\n`);
for (let failure of failures) {
let short_status = failure.short_status;
if (failure.token === 'failure') {
short_status += ": ";
short_status += failure.fail_reason;
}
let parts = [
String(failure.index + 1).padStart(5),
pad(failure.title.replace(/[\r\n]+/, " "), 32),
RESULT_TYPES[failure.token].color + pad(short_status, 20) + ANSI_RESET,
"ran for" + util.format_duration(failure.time_simulated).padStart(6, " "),
];
if (failure.token === 'failure') {
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
}
stdout.write(parts.join(" ") + "\n");
}
return {
num_passed,
num_missing,
num_failed: num_levels - num_passed - num_missing,
time_elapsed: total_real_elapsed,
time_simulated: total_tics / 20,
};
}
const TESTABLE_PACKS = [{
// FIXME lynx cc1...
/*
title: "CC1",
pack_path: 'levels/CC1.ccl',
solutions_path: 'levels/public_CHIPS-lynx.dac.tws',
ruleset: 'lynx',
}, {
*/
// FIXME the solution files aren't committed yet
/*
title: "CCLXP2",
pack_path: 'levels/CCLXP2.ccl',
solutions_path: 'levels/public_CCLXP2.dac.tws',
ruleset: 'lynx',
}, {
title: "CCLP3",
pack_path: 'levels/CCLP3.ccl',
solutions_path: 'levels/public_CCLP3-lynx.dac.tws',
ruleset: 'lynx',
}, {
title: "CCLP4",
pack_path: 'levels/CCLP4.ccl',
solutions_path: 'levels/public_CCLP4-lynx.dac.tws',
ruleset: 'lynx',
}, {
title: "CCLP1",
pack_path: 'levels/CCLP1.ccl',
solutions_path: 'levels/public_CCLP1-lynx.dac.tws',
ruleset: 'lynx',
}, {
*/
title: "CC2LP1",
pack_path: 'levels/CC2LP1.zip',
ruleset: 'steam-strict',
// FIXME add steam cc1, cc2, but optionally and from command line or something
// (these are just local symlinks to my steam directory lol)
/*
}, {
title: "CC1 (Steam)",
pack_path: 'levels/cc1',
ruleset: 'steam-strict',
isdir: true,
}, {
title: "CC2",
pack_path: 'levels/cc2',
ruleset: 'steam-strict',
isdir: true,
}, {
title: "CC2LP1-Voting",
pack_path: '_local-levels/CC2LP1-Voting',
ruleset: 'steam-strict',
isdir: true,
*/
}];
async function _scan_source(source) {
// FIXME copied wholesale from Splash.search_multi_source; need a real filesystem + searching api!
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
if (source._loaded_promise) {
await source._loaded_promise;
}
let paths = Object.keys(source.files);
// TODO should handle having multiple candidates, but this is good enough for now
paths.sort((a, b) => a.length - b.length);
for (let path of paths) {
let m = path.match(/[.]([^./]+)$/);
if (! m)
continue;
let ext = m[1];
// TODO this can't load an individual c2m, hmmm
if (ext === 'c2g') {
let buf = await source.get(path);
//await this.conductor.parse_and_load_game(buf, source, path);
// FIXME and this is from parse_and_load_game!!
let dir;
if (! path.match(/[/]/)) {
dir = '';
}
else {
dir = path.replace(/[/][^/]+$/, '');
}
return await format_c2g.parse_game(buf, source, dir);
}
}
// TODO else...? complain we couldn't find anything? list what we did find?? idk
}
async function main() {
let overall = {
num_passed: 0,
num_missing: 0,
num_failed: 0,
time_elapsed: 0,
time_simulated: 0,
};
for (let testdef of TESTABLE_PACKS) {
let pack;
if (testdef.isdir) {
let source = new LocalDirectorySource(testdef.pack_path);
pack = await _scan_source(source);
}
else {
let pack_data = await readFile(testdef.pack_path);
if (testdef.pack_path.match(/[.]zip$/)) {
let source = new util.ZipFileSource(pack_data.buffer);
pack = await _scan_source(source);
}
else {
pack = format_dat.parse_game(pack_data.buffer);
let solutions_data = await readFile(testdef.solutions_path);
let solutions = format_tws.parse_solutions(solutions_data.buffer);
pack.level_replays = solutions.levels;
}
}
pack.title = testdef.title;
let result = await test_pack(pack, testdef.ruleset);
for (let key of Object.keys(overall)) {
overall[key] += result[key];
}
}
let num_levels = overall.num_passed + overall.num_failed + overall.num_missing;
stdout.write("\n");
stdout.write(`${overall.num_passed}/${num_levels} = ${(overall.num_passed / num_levels * 100).toFixed(1)}% passed (${overall.num_failed} failed, ${overall.num_missing} missing replay)\n`);
stdout.write(`Simulated ${util.format_duration(overall.time_simulated)} of game time in ${util.format_duration(overall.time_elapsed)}, speed of ${(overall.time_simulated / overall.time_elapsed).toFixed(1)}×\n`);
}
main();
+1 -1
View File
@@ -1,4 +1,4 @@
import * as fflate from 'https://cdn.skypack.dev/fflate?min';
import * as fflate from './vendor/fflate.mjs';
import { DIRECTIONS, LAYERS, TICS_PER_SECOND } from './defs.js';
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
+10 -164
View File
@@ -1,8 +1,8 @@
// TODO bugs and quirks i'm aware of:
// - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor
import * as fflate from 'https://cdn.skypack.dev/fflate?min';
import * as fflate from './vendor/fflate.mjs';
import { DIRECTIONS, INPUT_BITS, TICS_PER_SECOND } from './defs.js';
import { COMPAT_FLAGS, COMPAT_RULESET_LABELS, COMPAT_RULESET_ORDER, DIRECTIONS, INPUT_BITS, TICS_PER_SECOND, compat_flags_for_ruleset } from './defs.js';
import * as c2g from './format-c2g.js';
import * as dat from './format-dat.js';
import * as format_base from './format-base.js';
@@ -2953,150 +2953,6 @@ class OptionsOverlay extends DialogOverlay {
super.close();
}
}
const COMPAT_RULESETS = [
['lexy', "Lexy"],
['steam', "Steam/CC2"],
['steam-strict', "Steam/CC2 (strict)"],
['lynx', "Lynx"],
['ms', "Microsoft"],
['custom', "Custom"],
];
// FIXME some of the names of the flags themselves kinda suck
const COMPAT_FLAGS = [
// Level loading
{
key: 'no_auto_convert_ccl_popwalls',
label: "Recessed walls under actors in CCL levels are left alone",
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
}, {
key: 'no_auto_convert_ccl_blue_walls',
label: "Blue walls under blocks in CCL levels are left alone",
rulesets: new Set(['steam-strict', 'lynx']),
},
// Core
{
key: 'use_lynx_loop',
label: "Game uses the Lynx-style update loop",
rulesets: new Set(['steam', 'steam-strict', 'lynx', 'ms']),
}, {
key: 'player_moves_last',
label: "Player always moves last",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'emulate_60fps',
label: "Game runs at 60 FPS",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'reuse_actor_slots',
label: "Game reuses slots in the actor list",
rulesets: new Set(['lynx']),
}, {
key: 'force_lynx_animation_lengths',
label: "Animations use Lynx duration",
rulesets: new Set(['lynx']),
},
// Tiles
{
// XXX this is goofy
key: 'tiles_react_instantly',
label: "Tiles react when approached",
rulesets: new Set(['ms']),
}, {
key: 'rff_actually_random',
label: "Random force floors are actually random",
rulesets: new Set(['ms']),
}, {
key: 'no_backwards_override',
label: "Player can't override backwards on a force floor",
rulesets: new Set(['lynx']),
}, {
key: 'traps_like_lynx',
label: "Traps eject faster, and even when already open",
rulesets: new Set(['lynx']),
},
// Items
{
key: 'no_immediate_detonate_bombs',
label: "Mines under non-player actors don't explode at level start",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'detonate_bombs_under_players',
label: "Mines under players explode at level start",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys",
rulesets: new Set(['ms']),
}, {
key: 'monsters_blocked_by_items',
label: "Monsters can't step on items to get the player",
rulesets: new Set(['lynx']),
},
// Blocks
{
key: 'no_early_push',
label: "Player pushes blocks at move time",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'use_legacy_hooking',
label: "Pulling blocks with the hook happens at decision time",
rulesets: new Set(['steam', 'steam-strict']),
}, {
// FIXME this is kind of annoying, there are some collision rules too
key: 'tanks_teeth_push_ice_blocks',
label: "Ice blocks emulate pgchip rules",
rulesets: new Set(['ms']),
}, {
key: 'emulate_spring_mining',
label: "Spring mining is possible",
rulesets: new Set(['steam-strict']),
/* XXX not implemented
}, {
key: 'emulate_flicking',
label: "Flicking is possible",
rulesets: new Set(['ms']),
*/
},
// Monsters
{
// TODO? in lynx they ignore the button while in motion too
// TODO what about in a trap, in every game??
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
// TODO yellow tanks seem to have memory too??
key: 'tanks_always_obey_button',
label: "Blue tanks always obey blue buttons",
rulesets: new Set(['steam-strict']),
}, {
key: 'tanks_ignore_button_while_moving',
label: "Blue tanks ignore blue buttons while moving",
rulesets: new Set(['lynx']),
}, {
key: 'blobs_use_tw_prng',
label: "Blobs use the Tile World RNG",
rulesets: new Set(['lynx']),
}, {
key: 'teeth_target_internal_position',
label: "Teeth target the player's internal position",
rulesets: new Set(['lynx']),
}, {
key: 'rff_blocks_monsters',
label: "Random force floors block monsters",
rulesets: new Set(['ms']),
}, {
key: 'bonking_isnt_instant',
label: "Bonking while sliding doesn't apply instantly",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'fire_allows_monsters',
label: "Fire doesn't block monsters",
rulesets: new Set(['ms']),
},
];
class CompatOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
@@ -3113,13 +2969,13 @@ class CompatOverlay extends DialogOverlay {
);
let button_set = mk('div.radio-faux-button-set');
for (let [ruleset, label] of COMPAT_RULESETS) {
for (let ruleset of COMPAT_RULESET_ORDER) {
button_set.append(mk('label',
mk('input', {type: 'radio', name: '__ruleset__', value: ruleset}),
mk('span.-button',
mk('img.compat-icon', {src: `icons/compat-${ruleset}.png`}),
mk('br'),
label,
COMPAT_RULESET_LABELS[ruleset],
),
));
}
@@ -3140,7 +2996,7 @@ class CompatOverlay extends DialogOverlay {
mk('input', {type: 'checkbox', name: compat.key}),
mk('span.-desc', compat.label),
);
for (let [ruleset, _] of COMPAT_RULESETS) {
for (let ruleset of COMPAT_RULESET_ORDER) {
if (ruleset === 'lexy' || ruleset === 'custom')
continue;
@@ -3263,12 +3119,12 @@ class PackTestDialog extends DialogOverlay {
});
let ruleset_dropdown = mk('select', {name: 'ruleset'});
for (let [ruleset, label] of COMPAT_RULESETS) {
for (let ruleset of COMPAT_RULESET_ORDER) {
if (ruleset === 'custom') {
ruleset_dropdown.append(mk('option', {value: ruleset, selected: 'selected'}, "Current ruleset"));
}
else {
ruleset_dropdown.append(mk('option', {value: ruleset}, label));
ruleset_dropdown.append(mk('option', {value: ruleset}, COMPAT_RULESET_LABELS[ruleset]));
}
}
this.main.append(
@@ -3302,12 +3158,7 @@ class PackTestDialog extends DialogOverlay {
compat = this.conductor.compat;
}
else {
compat = {};
for (let compatdef of COMPAT_FLAGS) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
}
}
compat = compat_flags_for_ruleset(ruleset);
}
for (let tbody of this.results.querySelectorAll('tbody')) {
@@ -3690,11 +3541,7 @@ class Conductor {
this._compat_ruleset = 'custom'; // Only used by the compat dialog
if (typeof this.stash.compat === 'string') {
this._compat_ruleset = this.stash.compat;
for (let compat of COMPAT_FLAGS) {
if (compat.rulesets.has(this.stash.compat)) {
this.compat[compat.key] = true;
}
}
this.compat = compat_flags_for_ruleset(this.stash.compat);
}
else {
Object.extend(this.compat, this.stash.compat);
@@ -4000,8 +3847,7 @@ class Conductor {
this._compat_ruleset = ruleset;
}
let label = COMPAT_RULESETS.filter(item => item[0] === ruleset)[0][1];
document.querySelector('#main-compat output').textContent = label;
document.querySelector('#main-compat output').textContent = COMPAT_RULESET_LABELS[ruleset];
this.compat = flags;
}
+6
View File
@@ -1445,6 +1445,12 @@ const TILE_TYPES = {
cloner: {
layer: LAYERS.terrain,
blocks_collision: COLLISION.real_player | COLLISION.block_cc1 | COLLISION.monster_solid,
populate_defaults(me) {
me.arrows = 0; // bitmask of glowing arrows (visual, no gameplay impact)
},
on_ready(me, level) {
me.arrows ??= 0;
},
traps(me, actor) {
return ! actor._clone_release;
},
+2 -4
View File
@@ -1,4 +1,4 @@
import * as fflate from 'https://cdn.skypack.dev/fflate?min';
import * as fflate from './vendor/fflate.mjs';
// Base class for custom errors
export class LLError extends Error {}
@@ -320,8 +320,6 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
}
}
}
window.walk_grid = walk_grid;
// console.table(Array.from(walk_grid(13, 27.133854389190674, 12.90625, 27.227604389190674)))
// Root class to indirect over where we might get files from
// - a pool of uploaded in-memory files
@@ -330,7 +328,7 @@ window.walk_grid = walk_grid;
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
// requirement that filenames are case-insensitive :/
class FileSource {
export class FileSource {
constructor() {}
// Get a file's contents as an ArrayBuffer
+3
View File
@@ -0,0 +1,3 @@
// fflate 0.6.7, obtained from https://cdn.skypack.dev/fflate?min
// original project at https://github.com/101arrowz/fflate
var gn={},bn=function(n,r,t,e,i){var a=gn[r]||(gn[r]=URL.createObjectURL(new Blob([n],{type:"text/javascript"}))),o=new Worker(a);return o.onerror=function(f){return i(f.error,null)},o.onmessage=function(f){return i(null,f.data)},o.postMessage(t,e),o},A=Uint8Array,R=Uint16Array,nr=Uint32Array,ur=new A([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),lr=new A([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Mr=new A([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),wn=function(n,r){for(var t=new R(31),e=0;e<31;++e)t[e]=r+=1<<n[e-1];for(var i=new nr(t[30]),e=1;e<30;++e)for(var a=t[e];a<t[e+1];++a)i[a]=a-t[e]<<5|e;return[t,i]},yn=wn(ur,2),Nr=yn[0],Ir=yn[1];Nr[28]=258,Ir[258]=28;for(var mn=wn(lr,0),zn=mn[0],Qr=mn[1],Ur=new R(32768),T=0;T<32768;++T){var ir=(T&43690)>>>1|(T&21845)<<1;ir=(ir&52428)>>>2|(ir&13107)<<2,ir=(ir&61680)>>>4|(ir&3855)<<4,Ur[T]=((ir&65280)>>>8|(ir&255)<<8)>>>1}for(var V=function(n,r,t){for(var e=n.length,i=0,a=new R(r);i<e;++i)++a[n[i]-1];var o=new R(r);for(i=0;i<r;++i)o[i]=o[i-1]+a[i-1]<<1;var f;if(t){f=new R(1<<r);var h=15-r;for(i=0;i<e;++i)if(n[i])for(var s=i<<4|n[i],u=r-n[i],l=o[n[i]-1]++<<u,c=l|(1<<u)-1;l<=c;++l)f[Ur[l]>>>h]=s}else for(f=new R(e),i=0;i<e;++i)n[i]&&(f[i]=Ur[o[n[i]-1]++]>>>15-n[i]);return f},tr=new A(288),T=0;T<144;++T)tr[T]=8;for(var T=144;T<256;++T)tr[T]=9;for(var T=256;T<280;++T)tr[T]=7;for(var T=280;T<288;++T)tr[T]=8;for(var vr=new A(32),T=0;T<32;++T)vr[T]=5;var xn=V(tr,9,0),An=V(tr,9,1),Dn=V(vr,5,0),Mn=V(vr,5,1),Gr=function(n){for(var r=n[0],t=1;t<n.length;++t)n[t]>r&&(r=n[t]);return r},X=function(n,r,t){var e=r/8|0;return(n[e]|n[e+1]<<8)>>(r&7)&t},Or=function(n,r){var t=r/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(r&7)},Cr=function(n){return(n/8|0)+(n&7&&1)},$=function(n,r,t){(r==null||r<0)&&(r=0),(t==null||t>n.length)&&(t=n.length);var e=new(n instanceof R?R:n instanceof nr?nr:A)(t-r);return e.set(n.subarray(r,t)),e},Fr=function(n,r,t){var e=n.length;if(!e||t&&!t.l&&e<5)return r||new A(0);var i=!r||t,a=!t||t.i;t||(t={}),r||(r=new A(e*3));var o=function(Tr){var Dr=r.length;if(Tr>Dr){var hr=new A(Math.max(Dr*2,Tr));hr.set(r),r=hr}},f=t.f||0,h=t.p||0,s=t.b||0,u=t.l,l=t.d,c=t.m,m=t.n,p=e*8;do{if(!u){t.f=f=X(n,h,1);var g=X(n,h+1,3);if(h+=3,g)if(g==1)u=An,l=Mn,c=9,m=5;else if(g==2){var y=X(n,h,31)+257,D=X(n,h+10,15)+4,F=y+X(n,h+5,31)+1;h+=14;for(var U=new A(F),x=new A(19),v=0;v<D;++v)x[Mr[v]]=X(n,h+v*3,7);h+=D*3;for(var P=Gr(x),k=(1<<P)-1,N=V(x,P,1),v=0;v<F;){var I=N[X(n,h,k)];h+=I&15;var w=I>>>4;if(w<16)U[v++]=w;else{var S=0,B=0;for(w==16?(B=3+X(n,h,3),h+=2,S=U[v-1]):w==17?(B=3+X(n,h,7),h+=3):w==18&&(B=11+X(n,h,127),h+=7);B--;)U[v++]=S}}var G=U.subarray(0,y),Z=U.subarray(y);c=Gr(G),m=Gr(Z),u=V(G,c,1),l=V(Z,m,1)}else throw"invalid block type";else{var w=Cr(h)+4,M=n[w-4]|n[w-3]<<8,z=w+M;if(z>e){if(a)throw"unexpected EOF";break}i&&o(s+M),r.set(n.subarray(w,z),s),t.b=s+=M,t.p=h=z*8;continue}if(h>p){if(a)throw"unexpected EOF";break}}i&&o(s+131072);for(var O=(1<<c)-1,H=(1<<m)-1,j=h;;j=h){var S=u[Or(n,h)&O],Q=S>>>4;if(h+=S&15,h>p){if(a)throw"unexpected EOF";break}if(!S)throw"invalid length/literal";if(Q<256)r[s++]=Q;else if(Q==256){j=h,u=null;break}else{var W=Q-254;if(Q>264){var v=Q-257,d=ur[v];W=X(n,h,(1<<d)-1)+Nr[v],h+=d}var _=l[Or(n,h)&H],J=_>>>4;if(!_)throw"invalid distance";h+=_&15;var Z=zn[J];if(J>3){var d=lr[J];Z+=Or(n,h)&(1<<d)-1,h+=d}if(h>p){if(a)throw"unexpected EOF";break}i&&o(s+131072);for(var q=s+W;s<q;s+=4)r[s]=r[s-Z],r[s+1]=r[s+1-Z],r[s+2]=r[s+2-Z],r[s+3]=r[s+3-Z];s=q}}t.l=u,t.p=j,t.b=s,u&&(f=1,t.m=c,t.d=l,t.n=m)}while(!f);return s==r.length?r:$(r,0,s)},b=function(n,r,t){t<<=r&7;var e=r/8|0;n[e]|=t,n[e+1]|=t>>>8},cr=function(n,r,t){t<<=r&7;var e=r/8|0;n[e]|=t,n[e+1]|=t>>>8,n[e+2]|=t>>>16},Er=function(n,r){for(var t=[],e=0;e<n.length;++e)n[e]&&t.push({s:e,f:n[e]});var i=t.length,a=t.slice();if(!i)return[er,0];if(i==1){var o=new A(t[0].s+1);return o[t[0].s]=1,[o,1]}t.sort(function(F,U){return F.f-U.f}),t.push({s:-1,f:25001});var f=t[0],h=t[1],s=0,u=1,l=2;for(t[0]={s:-1,f:f.f+h.f,l:f,r:h};u!=i-1;)f=t[t[s].f<t[l].f?s++:l++],h=t[s!=u&&t[s].f<t[l].f?s++:l++],t[u++]={s:-1,f:f.f+h.f,l:f,r:h};for(var c=a[0].s,e=1;e<i;++e)a[e].s>c&&(c=a[e].s);var m=new R(c+1),p=Pr(t[u-1],m,0);if(p>r){var e=0,g=0,w=p-r,M=1<<w;for(a.sort(function(U,x){return m[x.s]-m[U.s]||U.f-x.f});e<i;++e){var z=a[e].s;if(m[z]>r)g+=M-(1<<p-m[z]),m[z]=r;else break}for(g>>>=w;g>0;){var y=a[e].s;m[y]<r?g-=1<<r-m[y]++-1:++e}for(;e>=0&&g;--e){var D=a[e].s;m[D]==r&&(--m[D],++g)}p=r}return[new A(m),p]},Pr=function(n,r,t){return n.s==-1?Math.max(Pr(n.l,r,t+1),Pr(n.r,r,t+1)):r[n.s]=t},Vr=function(n){for(var r=n.length;r&&!n[--r];);for(var t=new R(++r),e=0,i=n[0],a=1,o=function(h){t[e++]=h},f=1;f<=r;++f)if(n[f]==i&&f!=r)++a;else{if(!i&&a>2){for(;a>138;a-=138)o(32754);a>2&&(o(a>10?a-11<<5|28690:a-3<<5|12305),a=0)}else if(a>3){for(o(i),--a;a>6;a-=6)o(8304);a>2&&(o(a-3<<5|8208),a=0)}for(;a--;)o(i);a=1,i=n[f]}return[t.subarray(0,e),r]},pr=function(n,r){for(var t=0,e=0;e<r.lenLine truncated