Add support for headless bulk testing
This commit is contained in:
parent
dac868edbf
commit
1f2a58d21c
12
README.md
12
README.md
@ -36,9 +36,17 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
|
|||||||
- Load levels directly from the BBC set list
|
- Load levels directly from the BBC set list
|
||||||
- Mouse support
|
- Mouse support
|
||||||
|
|
||||||
### Noble aspirations
|
## For developers
|
||||||
|
|
||||||
- New exclusive puzzle elements?? Embrace extend extinguish baby
|
It's all static JS; there's no build system. If you want to run it locally, just throw your favorite HTTP server at a checkout and open a browser. (Browsers won't allow XHR from `file:///` URLs, alas. If you don't have a favorite HTTP server, try `python -m http.server`.)
|
||||||
|
|
||||||
|
If you have Node installed, you can test the solutions included with the bundled level packs without needing a web browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
node js/headless/bulktest.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that solution playback is still not perfect, so don't be alarmed if you don't get 100% — only if you make a change and something regresses.
|
||||||
|
|
||||||
## Special thanks
|
## Special thanks
|
||||||
|
|
||||||
|
|||||||
156
js/defs.js
156
js/defs.js
@ -114,3 +114,159 @@ export const PICKUP_PRIORITIES = {
|
|||||||
player: 1, // players and doppelgangers; red keys (ignored by everything else)
|
player: 1, // players and doppelgangers; red keys (ignored by everything else)
|
||||||
real_player: 0,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -163,6 +163,10 @@ export class StoredPack {
|
|||||||
// error: any error received while loading the level
|
// error: any error received while loading the level
|
||||||
// bytes: Uint8Array of the encoded level data
|
// bytes: Uint8Array of the encoded level data
|
||||||
this.level_metadata = [];
|
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
|
// 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
|
// The editor stores inflated levels at times, so respect that
|
||||||
return meta.stored_level;
|
return meta.stored_level;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// Otherwise, attempt to load the level
|
// Otherwise, attempt to load the level
|
||||||
return this._level_loader(meta);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -409,8 +409,14 @@ const TILE_ENCODING = {
|
|||||||
},
|
},
|
||||||
0x44: {
|
0x44: {
|
||||||
name: 'cloner',
|
name: 'cloner',
|
||||||
// TODO visual directions bitmask, no gameplay impact, possible editor impact
|
modifier: {
|
||||||
modifier: null,
|
decode(tile, mod) {
|
||||||
|
tile.arrows = mod;
|
||||||
|
},
|
||||||
|
encode(tile) {
|
||||||
|
return tile.arrows;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
0x45: {
|
0x45: {
|
||||||
name: 'hint',
|
name: 'hint',
|
||||||
|
|||||||
@ -71,7 +71,6 @@ export function parse_solutions(bytes) {
|
|||||||
let step_parity = initial_state >> 3;
|
let step_parity = initial_state >> 3;
|
||||||
let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7];
|
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
|
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);
|
let total_duration = view.getUint32(p + 12, true);
|
||||||
|
|
||||||
// TODO split this off though
|
// TODO split this off though
|
||||||
|
|||||||
401
js/headless/bulktest.mjs
Normal file
401
js/headless/bulktest.mjs
Normal 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,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 { DIRECTIONS, LAYERS, TICS_PER_SECOND } from './defs.js';
|
||||||
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
|
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
|
||||||
|
|||||||
174
js/main.js
174
js/main.js
@ -1,8 +1,8 @@
|
|||||||
// TODO bugs and quirks i'm aware of:
|
// 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
|
// - 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 c2g from './format-c2g.js';
|
||||||
import * as dat from './format-dat.js';
|
import * as dat from './format-dat.js';
|
||||||
import * as format_base from './format-base.js';
|
import * as format_base from './format-base.js';
|
||||||
@ -2953,150 +2953,6 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
super.close();
|
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 {
|
class CompatOverlay extends DialogOverlay {
|
||||||
constructor(conductor) {
|
constructor(conductor) {
|
||||||
super(conductor);
|
super(conductor);
|
||||||
@ -3113,13 +2969,13 @@ class CompatOverlay extends DialogOverlay {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let button_set = mk('div.radio-faux-button-set');
|
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',
|
button_set.append(mk('label',
|
||||||
mk('input', {type: 'radio', name: '__ruleset__', value: ruleset}),
|
mk('input', {type: 'radio', name: '__ruleset__', value: ruleset}),
|
||||||
mk('span.-button',
|
mk('span.-button',
|
||||||
mk('img.compat-icon', {src: `icons/compat-${ruleset}.png`}),
|
mk('img.compat-icon', {src: `icons/compat-${ruleset}.png`}),
|
||||||
mk('br'),
|
mk('br'),
|
||||||
label,
|
COMPAT_RULESET_LABELS[ruleset],
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -3140,7 +2996,7 @@ class CompatOverlay extends DialogOverlay {
|
|||||||
mk('input', {type: 'checkbox', name: compat.key}),
|
mk('input', {type: 'checkbox', name: compat.key}),
|
||||||
mk('span.-desc', compat.label),
|
mk('span.-desc', compat.label),
|
||||||
);
|
);
|
||||||
for (let [ruleset, _] of COMPAT_RULESETS) {
|
for (let ruleset of COMPAT_RULESET_ORDER) {
|
||||||
if (ruleset === 'lexy' || ruleset === 'custom')
|
if (ruleset === 'lexy' || ruleset === 'custom')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@ -3263,12 +3119,12 @@ class PackTestDialog extends DialogOverlay {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ruleset_dropdown = mk('select', {name: 'ruleset'});
|
let ruleset_dropdown = mk('select', {name: 'ruleset'});
|
||||||
for (let [ruleset, label] of COMPAT_RULESETS) {
|
for (let ruleset of COMPAT_RULESET_ORDER) {
|
||||||
if (ruleset === 'custom') {
|
if (ruleset === 'custom') {
|
||||||
ruleset_dropdown.append(mk('option', {value: ruleset, selected: 'selected'}, "Current ruleset"));
|
ruleset_dropdown.append(mk('option', {value: ruleset, selected: 'selected'}, "Current ruleset"));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ruleset_dropdown.append(mk('option', {value: ruleset}, label));
|
ruleset_dropdown.append(mk('option', {value: ruleset}, COMPAT_RULESET_LABELS[ruleset]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.main.append(
|
this.main.append(
|
||||||
@ -3302,12 +3158,7 @@ class PackTestDialog extends DialogOverlay {
|
|||||||
compat = this.conductor.compat;
|
compat = this.conductor.compat;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
compat = {};
|
compat = compat_flags_for_ruleset(ruleset);
|
||||||
for (let compatdef of COMPAT_FLAGS) {
|
|
||||||
if (compatdef.rulesets.has(ruleset)) {
|
|
||||||
compat[compatdef.key] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let tbody of this.results.querySelectorAll('tbody')) {
|
for (let tbody of this.results.querySelectorAll('tbody')) {
|
||||||
@ -3690,11 +3541,7 @@ class Conductor {
|
|||||||
this._compat_ruleset = 'custom'; // Only used by the compat dialog
|
this._compat_ruleset = 'custom'; // Only used by the compat dialog
|
||||||
if (typeof this.stash.compat === 'string') {
|
if (typeof this.stash.compat === 'string') {
|
||||||
this._compat_ruleset = this.stash.compat;
|
this._compat_ruleset = this.stash.compat;
|
||||||
for (let compat of COMPAT_FLAGS) {
|
this.compat = compat_flags_for_ruleset(this.stash.compat);
|
||||||
if (compat.rulesets.has(this.stash.compat)) {
|
|
||||||
this.compat[compat.key] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Object.extend(this.compat, this.stash.compat);
|
Object.extend(this.compat, this.stash.compat);
|
||||||
@ -4000,8 +3847,7 @@ class Conductor {
|
|||||||
this._compat_ruleset = ruleset;
|
this._compat_ruleset = ruleset;
|
||||||
}
|
}
|
||||||
|
|
||||||
let label = COMPAT_RULESETS.filter(item => item[0] === ruleset)[0][1];
|
document.querySelector('#main-compat output').textContent = COMPAT_RULESET_LABELS[ruleset];
|
||||||
document.querySelector('#main-compat output').textContent = label;
|
|
||||||
|
|
||||||
this.compat = flags;
|
this.compat = flags;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1445,6 +1445,12 @@ const TILE_TYPES = {
|
|||||||
cloner: {
|
cloner: {
|
||||||
layer: LAYERS.terrain,
|
layer: LAYERS.terrain,
|
||||||
blocks_collision: COLLISION.real_player | COLLISION.block_cc1 | COLLISION.monster_solid,
|
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) {
|
traps(me, actor) {
|
||||||
return ! actor._clone_release;
|
return ! actor._clone_release;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
|
// Base class for custom errors
|
||||||
export class LLError extends Error {}
|
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
|
// Root class to indirect over where we might get files from
|
||||||
// - a pool of uploaded in-memory files
|
// - 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)
|
// - 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
|
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
|
||||||
// requirement that filenames are case-insensitive :/
|
// requirement that filenames are case-insensitive :/
|
||||||
class FileSource {
|
export class FileSource {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
// Get a file's contents as an ArrayBuffer
|
// Get a file's contents as an ArrayBuffer
|
||||||
|
|||||||
3
js/vendor/fflate.mjs
vendored
Normal file
3
js/vendor/fflate.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
3
package.json
Normal file
3
package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user