Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Projekt jest przeznaczony do lokalnego uruchamiania na Windowsie.
Pobierz gotowa paczke z GitHub Releases:

```text
otchlan-mapper-1.1.0.zip
otchlan-mapper-1.1.1.zip
```

Po pobraniu:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "otchlan-mapper",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
92 changes: 81 additions & 11 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const OTCHLAN_RELEASE_POSITION_READER = path.join(__dirname, "bin", "OtchlanMemo
const OTCHLAN_DEV_POSITION_READER = path.join(__dirname, "src", "OtchlanMemoryReader", "bin", "Release", "net8.0", "OtchlanMemoryReader.exe");
const OTCHLAN_POSITION_POLL_MS = Number(process.env.OTCHLAN_POSITION_POLL_MS || 100);
const OTCHLAN_MOB_POLL_MS = Number(process.env.OTCHLAN_MOB_POLL_MS || 1000);
const OTCHLAN_GAME_IDLE_AFTER_MS = Number(process.env.OTCHLAN_GAME_IDLE_AFTER_MS || 60000);
const OTCHLAN_GAME_IDLE_CHECK_MS = Number(process.env.OTCHLAN_GAME_IDLE_CHECK_MS || 5000);
const OTCHLAN_IDLE_POSITION_POLL_MS = Number(process.env.OTCHLAN_IDLE_POSITION_POLL_MS || 0);
const OTCHLAN_IDLE_MOB_POLL_MS = Number(process.env.OTCHLAN_IDLE_MOB_POLL_MS || 0);
const GAME_POSITION_LOG_INTERVAL_MS = Number(process.env.OTCHLAN_POSITION_LOG_INTERVAL_MS || 5000);
const DEFAULT_TERMINAL_COLS = Number(process.env.OTCHLAN_TERMINAL_COLS || 120);
const TERMINAL_ROWS = Number(process.env.OTCHLAN_TERMINAL_ROWS || 48);
Expand All @@ -55,6 +59,8 @@ let worldCacheEffectNames = null;
let worldCacheEffectNamesMtimeMs = 0;
let worldBuildTask = null;
let updateStatusCache = null;
let gameReaderMode = "stopped";
let lastGameInputAt = 0;
let terminalSize = {
cols: DEFAULT_TERMINAL_COLS,
rows: TERMINAL_ROWS
Expand Down Expand Up @@ -316,6 +322,7 @@ server.listen(PORT, () => {
});

await startWatcher();
setInterval(checkGameReaderIdleState, OTCHLAN_GAME_IDLE_CHECK_MS);

async function startWatcher() {
if (!existsSync(GAME_DIR)) {
Expand Down Expand Up @@ -810,6 +817,59 @@ function updateGameState(patch) {
broadcast("game-status", gameState);
}

function markGameInputActivity(reason = "input") {
lastGameInputAt = Date.now();
if (gameProcess) setGameReaderMode("active", reason);
}

function setGameReaderMode(mode, reason = "mode-change") {
const nextMode = mode === "active" || mode === "idle" ? mode : "stopped";
if (gameReaderMode === nextMode) return;
const previousMode = gameReaderMode;
gameReaderMode = nextMode;
writeServerLog({
level: "info",
event: "game-reader-mode-changed",
from: previousMode,
to: nextMode,
reason,
pid: gameProcess?.pid || null,
idleAfterMs: OTCHLAN_GAME_IDLE_AFTER_MS,
pollMs: getGameReaderPollMs(nextMode),
mobPollMs: getGameReaderMobPollMs(nextMode)
}).catch((error) => console.error("[server:error] failed to write reader mode log", error));
restartGamePositionReaderForMode();
}

function restartGamePositionReaderForMode() {
stopGamePositionReader();
if (!gameProcess?.pid || gameReaderMode === "stopped") return;
if (gameReaderMode === "idle" && shouldPauseGameReaderInIdle()) return;
startGamePositionReader(gameProcess.pid, gameReaderMode);
}

function shouldPauseGameReaderInIdle() {
return getGameReaderPollMs("idle") <= 0 || getGameReaderMobPollMs("idle") <= 0;
}

function getGameReaderPollMs(mode = gameReaderMode) {
return mode === "idle" ? OTCHLAN_IDLE_POSITION_POLL_MS : OTCHLAN_POSITION_POLL_MS;
}

function getGameReaderMobPollMs(mode = gameReaderMode) {
return mode === "idle" ? OTCHLAN_IDLE_MOB_POLL_MS : OTCHLAN_MOB_POLL_MS;
}

function checkGameReaderIdleState() {
if (!gameProcess) {
if (gameReaderMode !== "stopped") setGameReaderMode("stopped", "game-not-running");
return;
}
if (gameReaderMode !== "active") return;
if (!lastGameInputAt) return;
if (Date.now() - lastGameInputAt >= OTCHLAN_GAME_IDLE_AFTER_MS) setGameReaderMode("idle", "idle-timeout");
}

function claimMapper(instanceId, reason = "claim") {
const id = String(instanceId || "").trim();
if (!id) return { ok: false, error: "missing-instance-id", state: mapperState };
Expand Down Expand Up @@ -874,15 +934,15 @@ function startGame(args = ["/bezokien", "/nointro"]) {
exitCode: null,
message: `Otchlan uruchomiona w aplikacji (PID ${gameProcess.pid}).`
});
startGamePositionReader(gameProcess.pid);
markGameInputActivity("start-game");
rememberGameOutput({ source: "system", text: `Start: otchlan.exe ${safeArgs.join(" ")}` });

gameProcess.onData((chunk) => rememberGameOutput({ source: "stdout", text: decodeTerminalBytes(chunk) }));
gameProcess.onExit(({ exitCode }) => {
rememberGameOutput({ source: "system", text: `Gra zakonczona. Kod: ${exitCode ?? "brak"}` });
gameProcess = null;
lastGamePosition = null;
stopGamePositionReader();
setGameReaderMode("stopped", "game-exit");
updateGameState({ running: false, pid: null, exitCode, message: "Gra nie jest uruchomiona w aplikacji." });
});

Expand All @@ -893,6 +953,7 @@ function sendGameCommand(command) {
const value = String(command || "").trim();
if (!value) return { ok: false, error: "empty-command" };
if (!gameProcess) return { ok: false, error: "game-not-running" };
markGameInputActivity("game-command");
gameProcess.write(`${value}\r`);
rememberGameOutput({ source: "command", text: `> ${value}` });
writeServerLog({
Expand All @@ -908,6 +969,7 @@ function sendGameInput(data, instanceId) {
const value = String(data || "");
if (!value) return { ok: false, error: "empty-input" };
if (!gameProcess) return { ok: false, error: "game-not-running" };
markGameInputActivity("terminal-input");
claimMapper(instanceId, "terminal-input");
gameProcess.write(value);
broadcast("game-input", { data: value, at: new Date().toISOString() });
Expand Down Expand Up @@ -950,28 +1012,32 @@ function clampTerminalDimension(value, min, max, fallback) {

function stopGame() {
if (!gameProcess) return { ok: true, status: gameState };
setGameReaderMode("stopped", "game-stop");
gameProcess.kill();
return { ok: true, status: gameState };
}

function startGamePositionReader(pid) {
function startGamePositionReader(pid, mode = gameReaderMode) {
stopGamePositionReader();
if (!pid) return;
const nativeReaderPath = getNativePositionReaderPath();
const nativeReaderAvailable = Boolean(nativeReaderPath);
const powershellReaderAvailable = existsSync(OTCHLAN_POSITION_READER);
if (!nativeReaderAvailable && !powershellReaderAvailable) return;
memoryReaderBuffer = "";
const pollMs = getGameReaderPollMs(mode);
const mobPollMs = getGameReaderMobPollMs(mode);
if (pollMs <= 0 || mobPollMs <= 0) return;
const readerKind = nativeReaderAvailable ? "native-dotnet" : "powershell";
const readerCommand = nativeReaderAvailable ? nativeReaderPath : "powershell.exe";
const readerArgs = nativeReaderAvailable
? [
"-GamePid",
String(pid),
"-PollMs",
String(OTCHLAN_POSITION_POLL_MS),
String(pollMs),
"-MobPollMs",
String(OTCHLAN_MOB_POLL_MS)
String(mobPollMs)
]
: [
"-NoProfile",
Expand All @@ -982,21 +1048,23 @@ function startGamePositionReader(pid) {
"-GamePid",
String(pid),
"-PollMs",
String(OTCHLAN_POSITION_POLL_MS)
String(pollMs)
];
memoryReaderProcess = spawnProcess(readerCommand, readerArgs, {
cwd: __dirname,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
});
const spawnedReaderProcess = memoryReaderProcess;

writeServerLog({
level: "info",
event: "game-position-reader-started",
pid,
mode,
reader: readerKind,
pollMs: OTCHLAN_POSITION_POLL_MS,
mobPollMs: OTCHLAN_MOB_POLL_MS
pollMs,
mobPollMs
}).catch((error) => console.error("[server:error] failed to write position reader log", error));

memoryReaderProcess.stdout.setEncoding("utf8");
Expand All @@ -1019,16 +1087,18 @@ function startGamePositionReader(pid) {
}).catch((error) => console.error("[server:error] failed to write position reader stderr log", error));
});

memoryReaderProcess.on("exit", (code, signal) => {
spawnedReaderProcess.on("exit", (code, signal) => {
writeServerLog({
level: code ? "warn" : "info",
event: "game-position-reader-exit",
pid,
code,
signal
}).catch((error) => console.error("[server:error] failed to write position reader exit log", error));
memoryReaderProcess = null;
memoryReaderBuffer = "";
if (memoryReaderProcess === spawnedReaderProcess) {
memoryReaderProcess = null;
memoryReaderBuffer = "";
}
});
}

Expand Down
31 changes: 30 additions & 1 deletion test/game-position-memory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ test("server publishes Otchlan position from process memory", () => {
assert.match(serverSource, /const OTCHLAN_DEV_POSITION_READER = path\.join\(__dirname, "src", "OtchlanMemoryReader"/);
assert.match(serverSource, /const OTCHLAN_POSITION_POLL_MS = Number\(process\.env\.OTCHLAN_POSITION_POLL_MS \|\| 100\);/);
assert.match(serverSource, /const OTCHLAN_MOB_POLL_MS = Number\(process\.env\.OTCHLAN_MOB_POLL_MS \|\| 1000\);/);
assert.match(serverSource, /startGamePositionReader\(gameProcess\.pid\);/);
assert.match(serverSource, /const OTCHLAN_GAME_IDLE_AFTER_MS = Number\(process\.env\.OTCHLAN_GAME_IDLE_AFTER_MS \|\| 60000\);/);
assert.match(serverSource, /const OTCHLAN_GAME_IDLE_CHECK_MS = Number\(process\.env\.OTCHLAN_GAME_IDLE_CHECK_MS \|\| 5000\);/);
assert.match(serverSource, /const OTCHLAN_IDLE_POSITION_POLL_MS = Number\(process\.env\.OTCHLAN_IDLE_POSITION_POLL_MS \|\| 0\);/);
assert.match(serverSource, /const OTCHLAN_IDLE_MOB_POLL_MS = Number\(process\.env\.OTCHLAN_IDLE_MOB_POLL_MS \|\| 0\);/);
assert.match(serverSource, /let gameReaderMode = "stopped";/);
assert.match(serverSource, /let lastGameInputAt = 0;/);
assert.match(serverSource, /markGameInputActivity\("start-game"\);/);
assert.match(serverSource, /sendEvent\(client, "game-position", lastGamePosition\);/);
assert.match(serverSource, /broadcast\("game-position", lastGamePosition\);/);
assert.match(serverSource, /const readerKind = nativeReaderAvailable \? "native-dotnet" : "powershell";/);
Expand All @@ -38,6 +44,29 @@ test("server publishes Otchlan position from process memory", () => {
assert.match(serverSource, /canObserveMobs: environment\.canObserveMobs !== false/);
});

test("server pauses memory reader while game is idle and resumes on input", () => {
assert.match(serverSource, /setInterval\(checkGameReaderIdleState, OTCHLAN_GAME_IDLE_CHECK_MS\);/);
assert.match(serverSource, /function markGameInputActivity\(reason = "input"\) \{/);
assert.match(serverSource, /lastGameInputAt = Date\.now\(\);/);
assert.match(serverSource, /if \(gameProcess\) setGameReaderMode\("active", reason\);/);
assert.match(serverSource, /function setGameReaderMode\(mode, reason = "mode-change"\) \{/);
assert.match(serverSource, /event: "game-reader-mode-changed"/);
assert.match(serverSource, /restartGamePositionReaderForMode\(\);/);
assert.match(serverSource, /function restartGamePositionReaderForMode\(\) \{/);
assert.match(serverSource, /if \(gameReaderMode === "idle" && shouldPauseGameReaderInIdle\(\)\) return;/);
assert.match(serverSource, /function shouldPauseGameReaderInIdle\(\) \{/);
assert.match(serverSource, /return getGameReaderPollMs\("idle"\) <= 0 \|\| getGameReaderMobPollMs\("idle"\) <= 0;/);
assert.match(serverSource, /function checkGameReaderIdleState\(\) \{/);
assert.match(serverSource, /Date\.now\(\) - lastGameInputAt >= OTCHLAN_GAME_IDLE_AFTER_MS/);
assert.match(serverSource, /setGameReaderMode\("idle", "idle-timeout"\)/);
assert.match(serverSource, /markGameInputActivity\("game-command"\);/);
assert.match(serverSource, /markGameInputActivity\("terminal-input"\);/);
assert.match(serverSource, /setGameReaderMode\("stopped", "game-stop"\);/);
assert.match(serverSource, /setGameReaderMode\("stopped", "game-exit"\);/);
assert.match(serverSource, /const spawnedReaderProcess = memoryReaderProcess;/);
assert.match(serverSource, /if \(memoryReaderProcess === spawnedReaderProcess\) \{[\s\S]*memoryReaderProcess = null;[\s\S]*memoryReaderBuffer = "";/);
});

test("server enriches unnamed memory effects from extracted game symbols", () => {
assert.match(serverSource, /import \{ createReadStream, existsSync, readFileSync, statSync, watch \} from "node:fs";/);
assert.match(serverSource, /let worldCacheEffectNames = null;/);
Expand Down
13 changes: 9 additions & 4 deletions test/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { readFile } from "node:fs/promises";
const workflowSource = await readFile(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
const ciWorkflowSource = await readFile(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
const packageSource = await readFile(new URL("../package.json", import.meta.url), "utf8");
const packageLockSource = await readFile(new URL("../package-lock.json", import.meta.url), "utf8");
const readmeSource = await readFile(new URL("../README.md", import.meta.url), "utf8");
const pkg = JSON.parse(packageSource);
const packageLock = JSON.parse(packageLockSource);
const escapedPackageVersion = pkg.version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

test("package is prepared for 1.1 GitHub release", () => {
const pkg = JSON.parse(packageSource);
assert.equal(pkg.version, "1.1.0");
test("package is prepared for GitHub release", () => {
assert.match(pkg.version, /^\d+\.\d+\.\d+$/);
assert.equal(packageLock.version, pkg.version);
assert.equal(packageLock.packages[""].version, pkg.version);
assert.equal(pkg.scripts["release:build"], undefined);
assert.equal(pkg.scripts.stop, "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/stop-server.ps1");
});
Expand Down Expand Up @@ -41,7 +46,7 @@ test("GitHub Actions CI runs the full local verification suite", () => {

test("README documents user-facing release package", () => {
assert.match(readmeSource, /## Najprostsze Uruchomienie/);
assert.match(readmeSource, /otchlan-mapper-1\.1\.0\.zip/);
assert.match(readmeSource, new RegExp(`otchlan-mapper-${escapedPackageVersion}\\.zip`));
assert.match(readmeSource, /Uruchom `run\.cmd`/);
assert.match(readmeSource, /Ekstrahuj dane gry/);
assert.doesNotMatch(readmeSource, /git tag v1\.0\.0/);
Expand Down
Loading