diff --git a/.gitignore b/.gitignore
index 6ec44db..48eab00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ node_modules/
release/
dist/
.cache/
+.home/
# Reconstructed reproducibly from the pinned MiniDayZ Plus upstream commit.
# Keep game changes as explicit patches instead of silently vendoring generated files.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89835e3..0174caa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
All notable desktop-port changes are documented here. The embedded game remains MiniDayZ Plus 1.2.
+## [0.2.0] - 2026-07-14
+
+### Added
+
+- Complete desktop action bindings: WASD and arrow movement, inventory, interaction, combat, reload, weapon selection, aiming, perks, flare, talk, pause, and vehicle actions.
+- Mouse aliases for aiming, cycling weapons, and interaction while preserving the game's original left-click behavior.
+- An in-game `F1` controls card.
+- Deterministic patching of the pinned Construct 2 event sheet by cloning the original guarded touch actions as keyboard actions.
+- Automated checks for every injected binding, forced WASD mode, patch metadata, and renderer-side control installation.
+
+### Changed
+
+- Desktop builds now force the native MiniDayZ WASD movement group and remember `WASD` as the selected control scheme.
+- Legacy Construct 2 service-worker caches are cleared without touching save storage, preventing older embedded game data from shadowing a desktop update.
+- Windows packaging removes a redundant base `electron.exe` when cross-building, reducing the portable executable without changing runtime files.
+
## [0.1.0] - 2026-07-14
### Added
diff --git a/README.md b/README.md
index 47c2edf..fd24274 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ A reproducible Windows desktop port of **MiniDayZ Plus 1.2**. The game runs loca
## Current release
-- Desktop port: **v0.1.0**
+- Desktop port: **v0.2.0**
- Game build: **MiniDayZ Plus 1.2**
- Platform: Windows 10/11 x64
- Package: portable `.exe` (no installer required)
@@ -15,10 +15,26 @@ Download the newest executable from [GitHub Releases](https://github.com/lestx05
## Controls and desktop behavior
-- `F11`: toggle full screen
-- `Alt+F4`: close the game
-- Launch with `--windowed`: start in a resizable 4:3 window
-- Only one instance runs at a time
+| Input | Action |
+| --- | --- |
+| `WASD` / arrow keys | Move or drive |
+| `Space` | Attack, fire, or fire from a vehicle |
+| `F` | Alternate attack |
+| `E` | Interact, pick up, or leave a vehicle |
+| `R` | Reload |
+| `Q` / middle mouse button | Cycle weapon |
+| `1` / `2` / `3` | Select melee / primary firearm / pistol |
+| `Tab` | Open or close inventory |
+| `X` / right mouse button | Toggle aim |
+| `P` | Open perks and status |
+| `G` | Use the equipped flare |
+| `T` | Talk |
+| `Esc` | Pause and options |
+| `F1` | Show or hide the in-game controls card |
+| `F11` | Toggle full screen |
+| `Alt+F4` | Close the game |
+
+The left mouse button keeps the original touch behavior for menus, inventory, and on-screen controls. Launch with `--windowed` to start in a resizable 4:3 window; only one instance runs at a time.
Game saves use a stable private origin under Electron's `%APPDATA%\MiniDayZ PC` profile, so they persist between launches and desktop-port updates.
@@ -34,7 +50,7 @@ pnpm install --frozen-lockfile
pnpm start
```
-`pnpm start` downloads the pinned upstream MiniDayZ Plus files on first use and verifies the archive's SHA-256 checksum before extracting it. Later runs reuse the verified local copy.
+`pnpm start` downloads the pinned upstream MiniDayZ Plus files on first use, verifies the archive's SHA-256 checksum, and applies the versioned desktop-control patch. Later runs reuse the verified local copy and reapply the patch idempotently.
Useful commands:
diff --git a/desktop/game-controls.js b/desktop/game-controls.js
new file mode 100644
index 0000000..1463c79
--- /dev/null
+++ b/desktop/game-controls.js
@@ -0,0 +1,235 @@
+'use strict';
+
+(function createDesktopControls(root, factory) {
+ const api = factory();
+
+ if (typeof module === 'object' && module.exports) {
+ module.exports = api;
+ }
+
+ if (root?.document) {
+ root.MiniDayZPCControls = api;
+ api.install(root);
+ }
+}(typeof globalThis === 'undefined' ? this : globalThis, () => {
+ const VERSION = '0.2.0';
+ const PATCH_BINDING_COUNT = 17;
+ const ARROW_ALIASES = Object.freeze({
+ ArrowDown: { code: 'KeyS', key: 's', keyCode: 83 },
+ ArrowLeft: { code: 'KeyA', key: 'a', keyCode: 65 },
+ ArrowRight: { code: 'KeyD', key: 'd', keyCode: 68 },
+ ArrowUp: { code: 'KeyW', key: 'w', keyCode: 87 },
+ });
+ const MOUSE_ALIASES = Object.freeze({
+ 1: { code: 'KeyQ', key: 'q', keyCode: 81 },
+ 2: { code: 'KeyX', key: 'x', keyCode: 88 },
+ 3: { code: 'KeyQ', key: 'q', keyCode: 81 },
+ 4: { code: 'KeyE', key: 'e', keyCode: 69 },
+ });
+ const PREVENTED_KEYS = new Set([
+ ' ', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Tab',
+ ]);
+ const HELP_ROWS = Object.freeze([
+ ['WASD / flechas', 'Moverse o conducir'],
+ ['Espacio', 'Atacar / disparar'],
+ ['F', 'Ataque alternativo'],
+ ['E', 'Interactuar, recoger o salir del vehículo'],
+ ['R', 'Recargar'],
+ ['Q / botón central', 'Cambiar de arma'],
+ ['1 / 2 / 3', 'Cuerpo a cuerpo / arma principal / pistola'],
+ ['Tab', 'Abrir o cerrar inventario'],
+ ['X / clic derecho', 'Activar o desactivar apuntado'],
+ ['P', 'Ventana de ventajas y estado'],
+ ['G', 'Usar bengala equipada'],
+ ['T', 'Hablar'],
+ ['Esc', 'Pausa y opciones'],
+ ['F11', 'Pantalla completa'],
+ ['F1', 'Mostrar u ocultar esta ayuda'],
+ ]);
+
+ function isTypingTarget(target) {
+ const tagName = target?.tagName?.toLowerCase();
+ return target?.isContentEditable || tagName === 'input' || tagName === 'select' || tagName === 'textarea';
+ }
+
+ function createKeyboardEvent(windowObject, type, binding, repeat = false) {
+ const event = new windowObject.KeyboardEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ code: binding.code,
+ key: binding.key,
+ repeat,
+ });
+ Object.defineProperties(event, {
+ keyCode: { configurable: true, get: () => binding.keyCode },
+ which: { configurable: true, get: () => binding.keyCode },
+ });
+ return event;
+ }
+
+ function dispatchKeyboard(windowObject, type, binding, repeat = false) {
+ windowObject.document.dispatchEvent(createKeyboardEvent(windowObject, type, binding, repeat));
+ }
+
+ function createHelpOverlay(documentObject) {
+ const overlay = documentObject.createElement('section');
+ overlay.id = 'minidayz-pc-controls-help';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-label', 'Controles de MiniDayZ PC');
+ Object.assign(overlay.style, {
+ background: 'rgba(8, 12, 10, 0.94)',
+ border: '1px solid #80906a',
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.65)',
+ color: '#f1f1df',
+ display: 'none',
+ font: '14px/1.35 Arial, sans-serif',
+ left: '50%',
+ maxHeight: 'calc(100vh - 48px)',
+ maxWidth: 'min(620px, calc(100vw - 48px))',
+ overflow: 'auto',
+ padding: '18px 22px',
+ pointerEvents: 'none',
+ position: 'fixed',
+ top: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: '520px',
+ zIndex: '2147483647',
+ });
+
+ const title = documentObject.createElement('h2');
+ title.textContent = `MiniDayZ PC ${VERSION} — Controles`;
+ Object.assign(title.style, { fontSize: '18px', margin: '0 0 12px' });
+ overlay.append(title);
+
+ const table = documentObject.createElement('table');
+ Object.assign(table.style, { borderCollapse: 'collapse', width: '100%' });
+ for (const [key, action] of HELP_ROWS) {
+ const row = documentObject.createElement('tr');
+ const keyCell = documentObject.createElement('th');
+ const actionCell = documentObject.createElement('td');
+ keyCell.textContent = key;
+ actionCell.textContent = action;
+ Object.assign(keyCell.style, {
+ color: '#d7dc8a',
+ padding: '3px 14px 3px 0',
+ textAlign: 'left',
+ whiteSpace: 'nowrap',
+ });
+ Object.assign(actionCell.style, { padding: '3px 0' });
+ row.append(keyCell, actionCell);
+ table.append(row);
+ }
+ overlay.append(table);
+
+ const footer = documentObject.createElement('p');
+ footer.textContent = 'El clic izquierdo conserva la interacción táctil original para menús e inventario.';
+ Object.assign(footer.style, { color: '#b9b9aa', margin: '12px 0 0' });
+ overlay.append(footer);
+ documentObject.body.append(overlay);
+ return overlay;
+ }
+
+ function install(windowObject) {
+ if (windowObject.__MINIDAYZ_PC_CONTROLS__?.installed) {
+ return windowObject.__MINIDAYZ_PC_CONTROLS__;
+ }
+
+ const documentObject = windowObject.document;
+ let overlay = null;
+ let overlayVisible = false;
+ const mouseButtonsDown = new Set();
+
+ function setOverlayVisible(visible) {
+ overlay ??= createHelpOverlay(documentObject);
+ overlayVisible = visible;
+ overlay.style.display = visible ? 'block' : 'none';
+ }
+
+ documentObject.addEventListener('keydown', (event) => {
+ if (event.key === 'F1') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ setOverlayVisible(!overlayVisible);
+ return;
+ }
+
+ if (event.key === 'Escape' && overlayVisible) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ setOverlayVisible(false);
+ return;
+ }
+
+ if (isTypingTarget(event.target)) {
+ return;
+ }
+
+ const alias = ARROW_ALIASES[event.key];
+ if (alias) {
+ event.preventDefault();
+ dispatchKeyboard(windowObject, 'keydown', alias, event.repeat);
+ } else if (PREVENTED_KEYS.has(event.key)) {
+ event.preventDefault();
+ }
+ }, true);
+
+ documentObject.addEventListener('keyup', (event) => {
+ if (isTypingTarget(event.target)) {
+ return;
+ }
+ const alias = ARROW_ALIASES[event.key];
+ if (alias) {
+ event.preventDefault();
+ dispatchKeyboard(windowObject, 'keyup', alias);
+ }
+ }, true);
+
+ documentObject.addEventListener('contextmenu', (event) => {
+ if (event.target?.closest?.('#c2canvas, #c2canvasdiv')) {
+ event.preventDefault();
+ }
+ }, true);
+
+ documentObject.addEventListener('mousedown', (event) => {
+ const binding = MOUSE_ALIASES[event.button];
+ if (!binding || !event.target?.closest?.('#c2canvas, #c2canvasdiv')) {
+ return;
+ }
+ event.preventDefault();
+ mouseButtonsDown.add(event.button);
+ dispatchKeyboard(windowObject, 'keydown', binding);
+ }, true);
+
+ documentObject.addEventListener('mouseup', (event) => {
+ const binding = MOUSE_ALIASES[event.button];
+ if (!binding || !mouseButtonsDown.delete(event.button)) {
+ return;
+ }
+ event.preventDefault();
+ dispatchKeyboard(windowObject, 'keyup', binding);
+ }, true);
+
+ const status = Object.freeze({
+ bindings: PATCH_BINDING_COUNT,
+ installed: true,
+ version: VERSION,
+ });
+ Object.defineProperty(windowObject, '__MINIDAYZ_PC_CONTROLS__', {
+ configurable: false,
+ enumerable: false,
+ value: status,
+ writable: false,
+ });
+ return status;
+ }
+
+ return Object.freeze({
+ ARROW_ALIASES,
+ HELP_ROWS,
+ MOUSE_ALIASES,
+ PATCH_BINDING_COUNT,
+ VERSION,
+ createKeyboardEvent,
+ install,
+ });
+}));
diff --git a/desktop/main.js b/desktop/main.js
index b7381fc..b55bda7 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -14,6 +14,9 @@ const {
GAME_SCHEME,
createAssetResponse,
} = require('./game-protocol');
+const { version: DESKTOP_VERSION } = require('../package.json');
+
+const DESKTOP_BINDING_COUNT = 17;
const GAME_URL = `${GAME_SCHEME}://${GAME_HOST}/index.html`;
const GAME_ROOT = path.join(__dirname, '..', 'docs');
@@ -25,7 +28,6 @@ protocol.registerSchemesAsPrivileged([
{
scheme: GAME_SCHEME,
privileges: {
- allowServiceWorkers: true,
bypassCSP: false,
corsEnabled: true,
secure: true,
@@ -53,7 +55,7 @@ function finishSmokeTest(error) {
console.error(`[smoke-test] ${error.stack || error}`);
app.exit(1);
} else {
- console.log('[smoke-test] Construct 2 canvas and runtime loaded successfully.');
+ console.log('[smoke-test] Construct 2 runtime and MiniDayZ PC controls loaded successfully.');
app.exit(0);
}
}
@@ -165,8 +167,14 @@ function createMainWindow() {
const assetResponse = await fetch('config.xml', { cache: 'no-store' });
const assetText = await assetResponse.text();
+ const controlsResponse = await fetch('.minidayz-desktop-patch.json', { cache: 'no-store' });
+ const controlsPatch = controlsResponse.ok ? await controlsResponse.json() : null;
return {
assetLoaded: assetResponse.ok && assetText.includes('com.bistudio.minidayz.plus'),
+ controlsBindingCount: window.__MINIDAYZ_PC_CONTROLS__?.bindings,
+ controlsInstalled: window.__MINIDAYZ_PC_CONTROLS__?.installed === true,
+ controlsVersion: window.__MINIDAYZ_PC_CONTROLS__?.version,
+ controlsPatch,
hasCanvas: Boolean(canvas),
hasRuntime: typeof window.cr_createRuntime === 'function',
hasRuntimeInstance: Boolean(canvas?.c2runtime),
@@ -176,6 +184,12 @@ function createMainWindow() {
if (
!result.assetLoaded
+ || result.controlsBindingCount !== DESKTOP_BINDING_COUNT
+ || !result.controlsInstalled
+ || result.controlsVersion !== DESKTOP_VERSION
+ || result.controlsPatch?.version !== DESKTOP_VERSION
+ || result.controlsPatch?.bindingCount !== DESKTOP_BINDING_COUNT
+ || result.controlsPatch?.movement !== 'WASD'
|| !result.hasCanvas
|| !result.hasRuntime
|| !result.hasRuntimeInstance
@@ -206,7 +220,20 @@ function createMainWindow() {
}
});
- void window.loadURL(GAME_URL);
+ // The packaged game is already fully local. Clear legacy Construct 2 offline
+ // workers/caches so an update can never shadow a newer embedded data.js.
+ void window.webContents.session.clearStorageData({
+ storages: ['serviceworkers', 'cachestorage'],
+ }).then(() => window.loadURL(GAME_URL)).catch((error) => {
+ if (isSmokeTest) {
+ finishSmokeTest(error);
+ } else {
+ dialog.showErrorBox('MiniDayZ PC could not prepare local game files', error.stack || String(error));
+ }
+ if (!window.isDestroyed()) {
+ window.close();
+ }
+ });
return window;
}
diff --git a/package.json b/package.json
index 850050a..01bbc8c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "minidayz-pc",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"description": "Reproducible Windows desktop port of MiniDayZ Plus 1.2.",
"main": "desktop/main.js",
@@ -9,6 +9,7 @@
"packageManager": "pnpm@11.7.0",
"scripts": {
"sync:game": "node scripts/sync-game-assets.mjs",
+ "patch:game": "node scripts/patch-game-assets.mjs",
"start": "pnpm run sync:game && electron .",
"start:windowed": "pnpm run sync:game && electron . --windowed",
"smoke": "pnpm run sync:game && electron . --smoke-test",
@@ -21,6 +22,8 @@
"productName": "MiniDayZ PC",
"asar": true,
"compression": "maximum",
+ "afterPack": "./scripts/after-pack.cjs",
+ "afterSign": "./scripts/after-pack.cjs",
"npmRebuild": false,
"directories": {
"output": "release"
diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs
new file mode 100644
index 0000000..d3cda3f
--- /dev/null
+++ b/scripts/after-pack.cjs
@@ -0,0 +1,38 @@
+'use strict';
+
+const { access, rm } = require('node:fs/promises');
+const path = require('node:path');
+
+async function exists(filePath) {
+ try {
+ await access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function afterPack(context) {
+ if (context.electronPlatformName !== 'win32') {
+ return;
+ }
+
+ const productExecutable = path.join(
+ context.appOutDir,
+ `${context.packager.appInfo.productFilename}.exe`,
+ );
+ const baseExecutable = path.join(context.appOutDir, 'electron.exe');
+
+ if (
+ productExecutable.toLowerCase() !== baseExecutable.toLowerCase()
+ && await exists(productExecutable)
+ && await exists(baseExecutable)
+ ) {
+ await rm(baseExecutable);
+ console.log('Removed redundant electron.exe from the Windows package.');
+ }
+}
+
+module.exports = afterPack;
+module.exports.afterPack = afterPack;
+module.exports.default = afterPack;
diff --git a/scripts/desktop-control-bindings.mjs b/scripts/desktop-control-bindings.mjs
new file mode 100644
index 0000000..d5cc49f
--- /dev/null
+++ b/scripts/desktop-control-bindings.mjs
@@ -0,0 +1,164 @@
+export const DESKTOP_CONTROLS_VERSION = '0.2.0';
+export const DESKTOP_EVENT_GROUP = 'Desktop_controls';
+// 2_000_000_000 + major * 1_000_000 + minor * 1_000 + patch.
+export const DESKTOP_OFFLINE_CACHE_VERSION = 2_000_002_000;
+
+export const RUNTIME_IDS = Object.freeze({
+ functionPlugin: 189,
+ keyboardPlugin: 180,
+ keyDownCondition: 305,
+ keyPressedCondition: 453,
+ touchPlugin: 495,
+});
+
+// These source events belong to the checksum-pinned MiniDayZ Plus snapshot.
+// Cloning them keeps every original gameplay guard and action intact while
+// replacing only the touch condition with the matching keyboard condition.
+export const DESKTOP_BINDINGS = Object.freeze([
+ {
+ id: 'inventory',
+ label: 'Inventory',
+ key: 'Tab',
+ keyCode: 9,
+ mode: 'pressed',
+ sourceEventSid: 4706090438729328,
+ touchConditionId: 273,
+ touchObjectId: 497,
+ },
+ {
+ id: 'interact',
+ label: 'Interact / pick up',
+ key: 'E',
+ keyCode: 69,
+ mode: 'pressed',
+ sourceEventSid: 8880091309135125,
+ touchConditionId: 273,
+ touchObjectId: 512,
+ },
+ {
+ id: 'vehicle-exit',
+ label: 'Leave vehicle',
+ key: 'E',
+ keyCode: 69,
+ mode: 'pressed',
+ sourceEventSid: 893643204604230,
+ touchConditionId: 273,
+ touchObjectId: 807,
+ },
+ {
+ id: 'reload',
+ label: 'Reload',
+ key: 'R',
+ keyCode: 82,
+ mode: 'pressed',
+ sourceEventSid: 8302300112826202,
+ touchConditionId: 227,
+ touchObjectId: 522,
+ },
+ {
+ id: 'weapon-cycle',
+ label: 'Cycle weapon',
+ key: 'Q',
+ keyCode: 81,
+ mode: 'pressed',
+ sourceEventSid: 6889675909831562,
+ touchConditionId: 273,
+ touchObjectId: 509,
+ },
+ {
+ id: 'primary-press',
+ label: 'Primary attack',
+ key: 'Space',
+ keyCode: 32,
+ mode: 'pressed',
+ sourceEventSid: 7708888474205845,
+ touchConditionId: 273,
+ touchObjectId: 505,
+ },
+ {
+ id: 'primary-hold',
+ label: 'Automatic fire / held attack',
+ key: 'Space',
+ keyCode: 32,
+ mode: 'down',
+ sourceEventSid: 1671415525337555,
+ touchConditionId: 274,
+ touchObjectId: 505,
+ },
+ {
+ id: 'vehicle-fire',
+ label: 'Fire from vehicle',
+ key: 'Space',
+ keyCode: 32,
+ mode: 'down',
+ sourceEventSid: 932569909445593,
+ touchConditionId: 274,
+ touchObjectId: 922,
+ },
+ {
+ id: 'alternate-attack',
+ label: 'Alternate attack',
+ key: 'F',
+ keyCode: 70,
+ mode: 'pressed',
+ sourceEventSid: 372174106651111,
+ touchConditionId: 227,
+ touchObjectId: 945,
+ },
+ {
+ id: 'aim',
+ label: 'Toggle aim',
+ key: 'X',
+ keyCode: 88,
+ mode: 'pressed',
+ sourceEventSid: 509486699693314,
+ touchConditionId: 227,
+ touchObjectId: 798,
+ },
+ {
+ id: 'pause',
+ label: 'Pause / options',
+ key: 'Escape',
+ keyCode: 27,
+ mode: 'pressed',
+ sourceEventSid: 1982214218846053,
+ touchConditionId: 227,
+ touchObjectId: 288,
+ },
+ {
+ id: 'perks',
+ label: 'Perks and status',
+ key: 'P',
+ keyCode: 80,
+ mode: 'pressed',
+ sourceEventSid: 6088908557716181,
+ touchConditionId: 227,
+ touchObjectId: 602,
+ },
+ {
+ id: 'flare',
+ label: 'Use equipped flare',
+ key: 'G',
+ keyCode: 71,
+ mode: 'pressed',
+ sourceEventSid: 5900097328538972,
+ touchConditionId: 227,
+ touchObjectId: 642,
+ },
+ {
+ id: 'talk',
+ label: 'Talk',
+ key: 'T',
+ keyCode: 84,
+ mode: 'pressed',
+ sourceEventSid: 622335141267763,
+ touchConditionId: 227,
+ touchObjectId: 736,
+ },
+]);
+
+export const QUICK_WEAPON_BINDINGS = Object.freeze([
+ { id: 'melee', label: 'Select melee weapon', key: '1', keyCode: 49, functionName: 'Switch_to_melee' },
+ { id: 'firearm', label: 'Select primary firearm', key: '2', keyCode: 50, functionName: 'Switch_to_firearm' },
+ { id: 'pistol', label: 'Select pistol', key: '3', keyCode: 51, functionName: 'Switch_to_pistol' },
+]);
diff --git a/scripts/patch-game-assets.mjs b/scripts/patch-game-assets.mjs
new file mode 100644
index 0000000..1b61e55
--- /dev/null
+++ b/scripts/patch-game-assets.mjs
@@ -0,0 +1,363 @@
+import { copyFile, readFile, rename, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ DESKTOP_BINDINGS,
+ DESKTOP_CONTROLS_VERSION,
+ DESKTOP_EVENT_GROUP,
+ DESKTOP_OFFLINE_CACHE_VERSION,
+ QUICK_WEAPON_BINDINGS,
+ RUNTIME_IDS,
+} from './desktop-control-bindings.mjs';
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const defaultGameRoot = path.join(projectRoot, 'docs');
+const controlsSource = path.join(projectRoot, 'desktop', 'game-controls.js');
+const INDEX_PATCH_START = '';
+const INDEX_PATCH_END = '';
+const SID_FLOOR = 10_000_000_000_000;
+
+function clone(value) {
+ return structuredClone(value);
+}
+
+function visitArrays(value, callback) {
+ if (!Array.isArray(value)) {
+ return;
+ }
+
+ callback(value);
+ for (const entry of value) {
+ visitArrays(entry, callback);
+ }
+}
+
+function findEventBySid(value, sid) {
+ let found = null;
+ visitArrays(value, (entry) => {
+ if (!found && entry[0] === 0 && entry.length === 8 && entry[4] === sid) {
+ found = entry;
+ }
+ });
+ return found;
+}
+
+function referencesObject(condition, objectId) {
+ let found = false;
+ visitArrays(condition, (entry) => {
+ if (entry.length === 2 && entry[0] === 4 && entry[1] === objectId) {
+ found = true;
+ }
+ });
+ return found;
+}
+
+export function createSidAllocator(project) {
+ const used = new Set();
+ visitArrays(project, (entry) => {
+ for (const value of entry) {
+ if (typeof value === 'number' && Number.isInteger(value) && value >= SID_FLOOR) {
+ used.add(value);
+ }
+ }
+ });
+
+ let candidate = 98_000_000_000_000;
+ return () => {
+ while (used.has(candidate)) {
+ candidate += 1;
+ }
+ const sid = candidate;
+ used.add(sid);
+ candidate += 1;
+ return sid;
+ };
+}
+
+function remapLargeIds(value, allocateSid, replacements = new Map()) {
+ if (!Array.isArray(value)) {
+ return;
+ }
+
+ for (let index = 0; index < value.length; index += 1) {
+ const entry = value[index];
+ if (typeof entry === 'number' && Number.isInteger(entry) && entry >= SID_FLOOR) {
+ if (!replacements.has(entry)) {
+ replacements.set(entry, allocateSid());
+ }
+ value[index] = replacements.get(entry);
+ } else {
+ remapLargeIds(entry, allocateSid, replacements);
+ }
+ }
+}
+
+function keyboardCondition(keyCode, mode, allocateSid) {
+ const isPressed = mode === 'pressed';
+ return [
+ RUNTIME_IDS.keyboardPlugin,
+ isPressed ? RUNTIME_IDS.keyPressedCondition : RUNTIME_IDS.keyDownCondition,
+ null,
+ isPressed ? 1 : 0,
+ false,
+ false,
+ false,
+ allocateSid(),
+ false,
+ [[9, keyCode]],
+ ];
+}
+
+export function cloneTouchEventForKeyboard(sourceEvent, binding, allocateSid) {
+ const event = clone(sourceEvent);
+ remapLargeIds(event, allocateSid);
+
+ const conditionIndex = event[5].findIndex((condition) => (
+ condition?.[0] === RUNTIME_IDS.touchPlugin
+ && condition?.[1] === binding.touchConditionId
+ && referencesObject(condition, binding.touchObjectId)
+ ));
+
+ if (conditionIndex === -1) {
+ throw new Error(`Could not find the touch condition for desktop binding ${binding.id}.`);
+ }
+
+ event[5][conditionIndex] = keyboardCondition(binding.keyCode, binding.mode, allocateSid);
+ return event;
+}
+
+function createFunctionBindingEvent(binding, allocateSid) {
+ return [
+ 0,
+ null,
+ false,
+ null,
+ allocateSid(),
+ [keyboardCondition(binding.keyCode, 'pressed', allocateSid)],
+ [[
+ RUNTIME_IDS.functionPlugin,
+ 69,
+ null,
+ allocateSid(),
+ false,
+ [[1, [2, binding.functionName]], [13]],
+ ]],
+ [],
+ ];
+}
+
+function groupActionName(entry) {
+ if (entry?.[0] !== -1 || entry?.[1] !== 74) {
+ return null;
+ }
+ return entry?.[5]?.[0]?.[1]?.[1] ?? null;
+}
+
+function enforceDesktopMovement(project) {
+ const stats = {
+ controlDefaults: 0,
+ controlWrites: 0,
+ groupDefaults: 0,
+ groupWrites: 0,
+ storageWrites: 0,
+ };
+
+ visitArrays(project, (entry) => {
+ if (entry[0] === 1 && entry[1] === 'GUI_control_type') {
+ entry[3] = 2;
+ stats.controlDefaults += 1;
+ }
+
+ if (
+ entry[0] === 0
+ && Array.isArray(entry[1])
+ && ['Movement_wasd', 'Movement_stick', 'Movement_tap'].includes(entry[1][1])
+ ) {
+ entry[1][0] = entry[1][1] === 'Movement_wasd';
+ stats.groupDefaults += 1;
+ }
+
+ const groupName = groupActionName(entry);
+ if (groupName === 'Movement_wasd') {
+ entry[5][1] = [3, 1];
+ stats.groupWrites += 1;
+ } else if (groupName === 'Movement_stick' || groupName === 'Movement_tap') {
+ entry[5][1] = [3, 0];
+ stats.groupWrites += 1;
+ }
+
+ if (
+ entry[0] === -1
+ && entry[1] === 41
+ && entry?.[5]?.[0]?.[0] === 11
+ && entry?.[5]?.[0]?.[1] === 'GUI_control_type'
+ ) {
+ entry[5][1] = [7, [0, 2]];
+ stats.controlWrites += 1;
+ }
+
+ if (
+ entry[0] === 422
+ && entry[1] === 146
+ && entry?.[5]?.[0]?.[1]?.[1] === 'CONTROLS'
+ ) {
+ entry[5][1] = [7, [2, 'WASD']];
+ stats.storageWrites += 1;
+ }
+ });
+
+ if (
+ stats.controlDefaults < 1
+ || stats.controlWrites < 1
+ || stats.groupDefaults < 3
+ || stats.groupWrites < 3
+ || stats.storageWrites < 1
+ ) {
+ throw new Error(`The pinned game control structure changed unexpectedly: ${JSON.stringify(stats)}.`);
+ }
+
+ return stats;
+}
+
+export function applyDesktopControlsToProject(project) {
+ const eventSheets = project?.[6];
+ const gameEventSheet = eventSheets?.find((sheet) => sheet?.[0] === 'Game_events');
+ if (!gameEventSheet || !Array.isArray(gameEventSheet[1])) {
+ throw new Error('Could not find the MiniDayZ Game_events sheet.');
+ }
+
+ gameEventSheet[1] = gameEventSheet[1].filter((entry) => !(
+ entry?.[0] === 0
+ && Array.isArray(entry[1])
+ && entry[1][1] === DESKTOP_EVENT_GROUP
+ ));
+
+ const movementStats = enforceDesktopMovement(project);
+ const allocateSid = createSidAllocator(project);
+ const bindingEvents = DESKTOP_BINDINGS.map((binding) => {
+ const sourceEvent = findEventBySid(gameEventSheet, binding.sourceEventSid);
+ if (!sourceEvent) {
+ throw new Error(`Could not find source event ${binding.sourceEventSid} for ${binding.id}.`);
+ }
+ return cloneTouchEventForKeyboard(sourceEvent, binding, allocateSid);
+ });
+
+ bindingEvents.push(...QUICK_WEAPON_BINDINGS.map((binding) => (
+ createFunctionBindingEvent(binding, allocateSid)
+ )));
+
+ const groupSid = allocateSid();
+ gameEventSheet[1].push([
+ 0,
+ [true, DESKTOP_EVENT_GROUP],
+ false,
+ null,
+ groupSid,
+ [[
+ -1,
+ 38,
+ null,
+ 0,
+ false,
+ false,
+ false,
+ groupSid,
+ false,
+ [[1, [2, DESKTOP_EVENT_GROUP]]],
+ ]],
+ [],
+ bindingEvents,
+ ]);
+
+ return {
+ bindingCount: bindingEvents.length,
+ movement: movementStats,
+ };
+}
+
+function patchIndexHtml(indexHtml) {
+ const patchPattern = new RegExp(
+ `\\r?\\n\\r?\\n[\\t ]*${INDEX_PATCH_START}[\\s\\S]*?${INDEX_PATCH_END}`,
+ 'g',
+ );
+ const cleanHtml = indexHtml.replace(patchPattern, '');
+ const runtimeScript = '';
+ if (!cleanHtml.includes(runtimeScript)) {
+ throw new Error('Could not locate c2runtime.js in the pinned game index.');
+ }
+
+ const patchBlock = [
+ INDEX_PATCH_START,
+ '\t',
+ `\t${INDEX_PATCH_END}`,
+ ].join('\n');
+ return cleanHtml.replace(runtimeScript, `${runtimeScript}\n\n\t${patchBlock}`);
+}
+
+async function patchDataFile(gameRoot) {
+ const dataPath = path.join(gameRoot, 'data.js');
+ const temporaryPath = `${dataPath}.desktop-patch`;
+ const source = await readFile(dataPath, 'utf8');
+ const hasBom = source.charCodeAt(0) === 0xFEFF;
+ const data = JSON.parse(hasBom ? source.slice(1) : source);
+ const stats = applyDesktopControlsToProject(data.project);
+ const output = `${hasBom ? '\uFEFF' : ''}${JSON.stringify(data)}`;
+
+ await rm(temporaryPath, { force: true });
+ await writeFile(temporaryPath, output, 'utf8');
+ await rename(temporaryPath, dataPath);
+ return stats;
+}
+
+async function patchOfflineManifest(gameRoot) {
+ const offlinePath = path.join(gameRoot, 'offline.js');
+ const source = await readFile(offlinePath, 'utf8');
+ const hasBom = source.charCodeAt(0) === 0xFEFF;
+ const manifest = JSON.parse(hasBom ? source.slice(1) : source);
+ const desktopFiles = ['desktop-controls.js', '.minidayz-desktop-patch.json'];
+
+ manifest.version = DESKTOP_OFFLINE_CACHE_VERSION;
+ manifest.fileList = [
+ ...manifest.fileList.filter((entry) => !desktopFiles.includes(entry)),
+ ...desktopFiles,
+ ];
+ await writeFile(
+ offlinePath,
+ `${hasBom ? '\uFEFF' : ''}${JSON.stringify(manifest, null, '\t')}\n`,
+ 'utf8',
+ );
+}
+
+export async function applyDesktopGamePatches(gameRoot = defaultGameRoot) {
+ const stats = await patchDataFile(gameRoot);
+ const indexPath = path.join(gameRoot, 'index.html');
+ const indexHtml = await readFile(indexPath, 'utf8');
+ await writeFile(indexPath, patchIndexHtml(indexHtml), 'utf8');
+ await copyFile(controlsSource, path.join(gameRoot, 'desktop-controls.js'));
+
+ const metadata = {
+ version: DESKTOP_CONTROLS_VERSION,
+ eventGroup: DESKTOP_EVENT_GROUP,
+ bindingCount: stats.bindingCount,
+ bindings: [
+ ...DESKTOP_BINDINGS.map(({ id, key, mode }) => ({ id, key, mode })),
+ ...QUICK_WEAPON_BINDINGS.map(({ id, key }) => ({ id, key, mode: 'pressed' })),
+ ],
+ movement: 'WASD',
+ offlineCacheVersion: DESKTOP_OFFLINE_CACHE_VERSION,
+ };
+ await writeFile(
+ path.join(gameRoot, '.minidayz-desktop-patch.json'),
+ `${JSON.stringify(metadata, null, 2)}\n`,
+ 'utf8',
+ );
+ await patchOfflineManifest(gameRoot);
+
+ return metadata;
+}
+
+const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
+if (import.meta.url === invokedPath) {
+ const metadata = await applyDesktopGamePatches();
+ console.log(`Applied MiniDayZ PC controls ${metadata.version} (${metadata.bindingCount} bindings).`);
+}
diff --git a/scripts/sync-game-assets.mjs b/scripts/sync-game-assets.mjs
index aa91ed1..b3041a8 100644
--- a/scripts/sync-game-assets.mjs
+++ b/scripts/sync-game-assets.mjs
@@ -17,6 +17,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import extract from 'extract-zip';
+import { applyDesktopGamePatches } from './patch-game-assets.mjs';
const UPSTREAM_REPOSITORY = 'NextDev65/MiniDayZ';
const UPSTREAM_COMMIT = '40ac9cf58af806e2d7c1c0638f6c3214042239b7';
@@ -115,6 +116,8 @@ async function downloadArchive() {
async function syncGame() {
if (!force && await hasCompleteGame(gameDirectory)) {
console.log('MiniDayZ Plus game assets are already present.');
+ const patch = await applyDesktopGamePatches(gameDirectory);
+ console.log(`Applied MiniDayZ PC controls ${patch.version} (${patch.bindingCount} bindings).`);
return;
}
@@ -160,6 +163,8 @@ async function syncGame() {
await readFile(path.join(gameDirectory, '.minidayz-source.json'), 'utf8'),
);
console.log(`Synced MiniDayZ Plus from ${sourceRecord.repository}@${sourceRecord.commit.slice(0, 8)}.`);
+ const patch = await applyDesktopGamePatches(gameDirectory);
+ console.log(`Applied MiniDayZ PC controls ${patch.version} (${patch.bindingCount} bindings).`);
}
await syncGame();
diff --git a/scripts/verify-project.mjs b/scripts/verify-project.mjs
index 51d0421..61c60c7 100644
--- a/scripts/verify-project.mjs
+++ b/scripts/verify-project.mjs
@@ -2,6 +2,14 @@ import { spawnSync } from 'node:child_process';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import {
+ DESKTOP_BINDINGS,
+ DESKTOP_CONTROLS_VERSION,
+ DESKTOP_EVENT_GROUP,
+ DESKTOP_OFFLINE_CACHE_VERSION,
+ QUICK_WEAPON_BINDINGS,
+ RUNTIME_IDS,
+} from './desktop-control-bindings.mjs';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const gameRoot = path.join(projectRoot, 'docs');
@@ -29,7 +37,30 @@ async function countFiles(directory) {
return total;
}
-for (const sourceFile of ['desktop/main.js', 'desktop/game-protocol.js']) {
+function visitArrays(value, callback) {
+ if (!Array.isArray(value)) {
+ return;
+ }
+ callback(value);
+ for (const entry of value) {
+ visitArrays(entry, callback);
+ }
+}
+
+function countOccurrences(source, fragment) {
+ return source.split(fragment).length - 1;
+}
+
+const syntaxCheckedFiles = [
+ 'desktop/main.js',
+ 'desktop/game-controls.js',
+ 'desktop/game-protocol.js',
+ 'scripts/desktop-control-bindings.mjs',
+ 'scripts/after-pack.cjs',
+ 'scripts/patch-game-assets.mjs',
+ 'scripts/sync-game-assets.mjs',
+];
+for (const sourceFile of syntaxCheckedFiles) {
await verifyFile(sourceFile, 100);
const syntaxCheck = spawnSync(process.execPath, ['--check', path.join(projectRoot, sourceFile)], {
encoding: 'utf8',
@@ -43,6 +74,8 @@ await verifyFile('docs/index.html', 1_000);
await verifyFile('docs/c2runtime.js', 100_000);
await verifyFile('docs/data.js', 5_000_000);
await verifyFile('docs/icon-256.png', 1_000);
+await verifyFile('docs/desktop-controls.js', 1_000);
+await verifyFile('docs/.minidayz-desktop-patch.json', 100);
const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8'));
if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) {
@@ -51,6 +84,15 @@ if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) {
if (packageJson.main !== 'desktop/main.js') {
fail('package.json must use desktop/main.js as its Electron entry point.');
}
+if (packageJson.version !== DESKTOP_CONTROLS_VERSION) {
+ fail(`Package ${packageJson.version} and control patch ${DESKTOP_CONTROLS_VERSION} versions differ.`);
+}
+if (
+ packageJson.build?.afterPack !== './scripts/after-pack.cjs'
+ || packageJson.build?.afterSign !== './scripts/after-pack.cjs'
+) {
+ fail('Electron builds must run the Windows package cleanup hook.');
+}
const packagedFiles = new Set(packageJson.build?.files ?? []);
for (const requiredPattern of ['desktop/**/*', 'docs/**/*', 'package.json']) {
@@ -63,10 +105,101 @@ const indexHtml = await readFile(path.join(gameRoot, 'index.html'), 'utf8');
if (!indexHtml.includes('id="c2canvas"') || !indexHtml.includes('c2runtime.js')) {
fail('docs/index.html is not a recognizable Construct 2 MiniDayZ export.');
}
+if (
+ countOccurrences(indexHtml, '') !== 1
+ || countOccurrences(indexHtml, '') !== 1
+) {
+ fail('docs/index.html must load exactly one copy of the MiniDayZ PC controls bridge.');
+}
+
+const controlSource = await readFile(path.join(projectRoot, 'desktop', 'game-controls.js'), 'utf8');
+const embeddedControlSource = await readFile(path.join(gameRoot, 'desktop-controls.js'), 'utf8');
+if (embeddedControlSource !== controlSource) {
+ fail('The embedded desktop controls do not match desktop/game-controls.js.');
+}
+
+const patchMetadata = JSON.parse(
+ await readFile(path.join(gameRoot, '.minidayz-desktop-patch.json'), 'utf8'),
+);
+const expectedBindings = [...DESKTOP_BINDINGS, ...QUICK_WEAPON_BINDINGS];
+if (
+ patchMetadata.version !== DESKTOP_CONTROLS_VERSION
+ || patchMetadata.eventGroup !== DESKTOP_EVENT_GROUP
+ || patchMetadata.bindingCount !== expectedBindings.length
+ || patchMetadata.movement !== 'WASD'
+ || patchMetadata.offlineCacheVersion !== DESKTOP_OFFLINE_CACHE_VERSION
+) {
+ fail(`Desktop patch metadata is inconsistent: ${JSON.stringify(patchMetadata)}.`);
+}
+
+const offlineSource = await readFile(path.join(gameRoot, 'offline.js'), 'utf8');
+const offlineManifest = JSON.parse(
+ offlineSource.charCodeAt(0) === 0xFEFF ? offlineSource.slice(1) : offlineSource,
+);
+if (
+ offlineManifest.version !== DESKTOP_OFFLINE_CACHE_VERSION
+ || !offlineManifest.fileList.includes('desktop-controls.js')
+ || !offlineManifest.fileList.includes('.minidayz-desktop-patch.json')
+) {
+ fail('The Construct 2 offline manifest does not include the current desktop patch.');
+}
+
+const dataSource = await readFile(path.join(gameRoot, 'data.js'), 'utf8');
+const gameData = JSON.parse(dataSource.charCodeAt(0) === 0xFEFF ? dataSource.slice(1) : dataSource);
+const gameEventSheet = gameData.project?.[6]?.find((sheet) => sheet?.[0] === 'Game_events');
+const desktopGroups = gameEventSheet?.[1]?.filter((entry) => (
+ entry?.[0] === 0 && entry?.[1]?.[1] === DESKTOP_EVENT_GROUP
+)) ?? [];
+if (desktopGroups.length !== 1 || desktopGroups[0][1][0] !== true) {
+ fail(`Expected exactly one active ${DESKTOP_EVENT_GROUP} event group.`);
+}
+
+const desktopEvents = desktopGroups[0][7];
+if (desktopEvents.length !== expectedBindings.length) {
+ fail(`Expected ${expectedBindings.length} desktop events, found ${desktopEvents.length}.`);
+}
+
+const actualBindingSignatures = desktopEvents.map((event) => {
+ const condition = event?.[5]?.find((entry) => (
+ entry?.[0] === RUNTIME_IDS.keyboardPlugin
+ && [RUNTIME_IDS.keyDownCondition, RUNTIME_IDS.keyPressedCondition].includes(entry?.[1])
+ ));
+ if (!condition) {
+ fail('A desktop event does not have a direct keyboard condition.');
+ }
+ const mode = condition[1] === RUNTIME_IDS.keyPressedCondition ? 'pressed' : 'down';
+ return `${condition?.[9]?.[0]?.[1]}:${mode}`;
+}).sort();
+const expectedBindingSignatures = expectedBindings.map((binding) => (
+ `${binding.keyCode}:${binding.mode ?? 'pressed'}`
+)).sort();
+if (JSON.stringify(actualBindingSignatures) !== JSON.stringify(expectedBindingSignatures)) {
+ fail(`Injected keyboard bindings differ from the manifest: ${actualBindingSignatures.join(', ')}.`);
+}
+
+const movementGroups = new Map();
+visitArrays(gameData.project, (entry) => {
+ if (
+ entry?.[0] === 0
+ && Array.isArray(entry?.[1])
+ && ['Movement_wasd', 'Movement_stick', 'Movement_tap'].includes(entry[1][1])
+ ) {
+ movementGroups.set(entry[1][1], entry[1][0]);
+ }
+});
+if (
+ movementGroups.get('Movement_wasd') !== true
+ || movementGroups.get('Movement_stick') !== false
+ || movementGroups.get('Movement_tap') !== false
+) {
+ fail(`Desktop movement groups are not locked to WASD: ${JSON.stringify(Object.fromEntries(movementGroups))}.`);
+}
const gameFileCount = await countFiles(gameRoot);
if (gameFileCount < 1_800) {
fail(`Game asset set is incomplete: found only ${gameFileCount} files.`);
}
-console.log(`Verified MiniDayZ PC v${packageJson.version}: ${gameFileCount} game files are ready.`);
+console.log(
+ `Verified MiniDayZ PC v${packageJson.version}: ${expectedBindings.length} desktop bindings and ${gameFileCount} game files are ready.`,
+);
diff --git a/test/after-pack.test.js b/test/after-pack.test.js
new file mode 100644
index 0000000..70b2d07
--- /dev/null
+++ b/test/after-pack.test.js
@@ -0,0 +1,41 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { access, mkdtemp, rm, writeFile } = require('node:fs/promises');
+const { tmpdir } = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const afterPack = require('../scripts/after-pack.cjs');
+
+async function exists(filePath) {
+ return access(filePath).then(() => true, () => false);
+}
+
+test('removes only the redundant Electron binary from Windows packages', async (context) => {
+ const appOutDir = await mkdtemp(path.join(tmpdir(), 'minidayz-after-pack-'));
+ context.after(() => rm(appOutDir, { recursive: true, force: true }));
+ const productExecutable = path.join(appOutDir, 'MiniDayZ PC.exe');
+ const baseExecutable = path.join(appOutDir, 'electron.exe');
+ await writeFile(productExecutable, 'product');
+ await writeFile(baseExecutable, 'base');
+
+ await afterPack({
+ appOutDir,
+ electronPlatformName: 'win32',
+ packager: { appInfo: { productFilename: 'MiniDayZ PC' } },
+ });
+
+ assert.equal(await exists(productExecutable), true);
+ assert.equal(await exists(baseExecutable), false);
+});
+
+test('does not alter non-Windows packages', async (context) => {
+ const appOutDir = await mkdtemp(path.join(tmpdir(), 'minidayz-after-pack-'));
+ context.after(() => rm(appOutDir, { recursive: true, force: true }));
+ const baseExecutable = path.join(appOutDir, 'electron.exe');
+ await writeFile(baseExecutable, 'base');
+
+ await afterPack({ appOutDir, electronPlatformName: 'linux' });
+
+ assert.equal(await exists(baseExecutable), true);
+});
diff --git a/test/game-controls-patch.test.js b/test/game-controls-patch.test.js
new file mode 100644
index 0000000..b35288c
--- /dev/null
+++ b/test/game-controls-patch.test.js
@@ -0,0 +1,58 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+
+test('clones a guarded touch action as a keyboard action without mutating its source', async () => {
+ const { cloneTouchEventForKeyboard } = await import('../scripts/patch-game-assets.mjs');
+ const source = [
+ 0,
+ null,
+ false,
+ null,
+ 47_060_904_387_293,
+ [[495, 273, null, 1, false, false, false, 47_060_904_387_294, false, [[4, 497]]]],
+ [[-1, 41, null, 47_060_904_387_295, false, [[11, 'Inventory_open'], [7, [0, 1]]]]],
+ [],
+ ];
+ const sourceSnapshot = structuredClone(source);
+ let nextSid = 98_000_000_001_000;
+ const cloned = cloneTouchEventForKeyboard(source, {
+ id: 'inventory',
+ keyCode: 9,
+ mode: 'pressed',
+ touchConditionId: 273,
+ touchObjectId: 497,
+ }, () => nextSid++);
+
+ assert.deepEqual(source, sourceSnapshot);
+ assert.notEqual(cloned[4], source[4]);
+ assert.deepEqual(cloned[5][0].slice(0, 4), [180, 453, null, 1]);
+ assert.deepEqual(cloned[5][0][9], [[9, 9]]);
+ assert.notEqual(cloned[6][0][3], source[6][0][3]);
+});
+
+test('uses the continuous keyboard condition for held actions', async () => {
+ const { cloneTouchEventForKeyboard } = await import('../scripts/patch-game-assets.mjs');
+ const source = [
+ 0,
+ null,
+ false,
+ null,
+ 47_060_904_387_296,
+ [[495, 274, null, 1, false, false, false, 47_060_904_387_297, false, [[4, 505]]]],
+ [],
+ [],
+ ];
+ let nextSid = 98_000_000_002_000;
+ const cloned = cloneTouchEventForKeyboard(source, {
+ id: 'primary-hold',
+ keyCode: 32,
+ mode: 'down',
+ touchConditionId: 274,
+ touchObjectId: 505,
+ }, () => nextSid++);
+
+ assert.deepEqual(cloned[5][0].slice(0, 4), [180, 305, null, 0]);
+ assert.deepEqual(cloned[5][0][9], [[9, 32]]);
+});
diff --git a/test/game-controls.test.js b/test/game-controls.test.js
new file mode 100644
index 0000000..364d93b
--- /dev/null
+++ b/test/game-controls.test.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const controls = require('../desktop/game-controls');
+
+class FakeKeyboardEvent extends Event {
+ constructor(type, options = {}) {
+ super(type, options);
+ this.code = options.code ?? '';
+ this.key = options.key ?? '';
+ this.repeat = options.repeat ?? false;
+ }
+}
+
+function createFakeWindow() {
+ const document = new EventTarget();
+ document.closest = (selector) => (
+ selector === '#c2canvas, #c2canvasdiv' ? document : null
+ );
+ return { document, KeyboardEvent: FakeKeyboardEvent };
+}
+
+test('publishes the complete v0.2.0 desktop-control manifest', () => {
+ assert.equal(controls.VERSION, '0.2.0');
+ assert.equal(controls.PATCH_BINDING_COUNT, 17);
+ assert.equal(controls.ARROW_ALIASES.ArrowUp.keyCode, 87);
+ assert.equal(controls.MOUSE_ALIASES[2].keyCode, 88);
+ assert.ok(controls.HELP_ROWS.some(([key]) => key === 'Tab'));
+});
+
+test('turns arrow input into Construct-compatible WASD keyboard events', () => {
+ const windowObject = createFakeWindow();
+ controls.install(windowObject);
+ const received = [];
+ windowObject.document.addEventListener('keydown', (event) => {
+ received.push({ key: event.key, which: event.which });
+ });
+
+ const arrowEvent = new FakeKeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowUp',
+ });
+ windowObject.document.dispatchEvent(arrowEvent);
+
+ assert.equal(arrowEvent.defaultPrevented, true);
+ assert.deepEqual(received, [
+ { key: 'w', which: 87 },
+ { key: 'ArrowUp', which: undefined },
+ ]);
+});
+
+test('maps right-click to the aim key without replacing left-click', () => {
+ const windowObject = createFakeWindow();
+ controls.install(windowObject);
+ const received = [];
+ windowObject.document.addEventListener('keydown', (event) => received.push(event.which));
+
+ const rightClick = new Event('mousedown', { bubbles: true, cancelable: true });
+ Object.defineProperty(rightClick, 'button', { value: 2 });
+ windowObject.document.dispatchEvent(rightClick);
+
+ const leftClick = new Event('mousedown', { bubbles: true, cancelable: true });
+ Object.defineProperty(leftClick, 'button', { value: 0 });
+ windowObject.document.dispatchEvent(leftClick);
+
+ assert.equal(rightClick.defaultPrevented, true);
+ assert.equal(leftClick.defaultPrevented, false);
+ assert.deepEqual(received, [88]);
+});