t
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled

This commit is contained in:
33333-33333 2026-08-01 22:31:04 +09:00
commit 9d70afb4cc
42 changed files with 1266 additions and 630 deletions

View file

@ -25,3 +25,49 @@ jobs:
node-version: 24
- run: npm install --ignore-scripts
- run: npm run test:ci
production-bridge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install --ignore-scripts
- name: Install production bridge runtimes
run: sudo apt-get update && sudo apt-get install -y php-cli apache2 libapache2-mod-php
- run: npm run test:bridge
- name: Exercise real Apache rewrite and proxy
shell: bash
run: |
set -euo pipefail
sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers
sudo tee /etc/apache2/conf-available/linkfield-ci.conf >/dev/null <<'EOF'
ServerTokens Prod
ServerSignature Off
<Directory /var/www/html/link-field>
AllowOverride All
Require all granted
</Directory>
EOF
sudo a2enconf linkfield-ci
sudo rm -rf /var/www/html/link-field
sudo mkdir -p /var/www/html/link-field
sudo chown -R "$USER":"$USER" /var/www/html/link-field
LINK_FIELD_PUBLIC_DIR=/var/www/html/link-field node scripts/service-control.js deploy
test_root="$(mktemp -d)"
LINK_FIELD_PUBLIC_DIR=/var/www/html/link-field LINK_FIELD_TEST_DATA_ROOT="$test_root" LINK_FIELD_APACHE_BRIDGE=1 HOST=127.0.0.1 PORT=43129 node server.js >"$test_root/server.log" 2>&1 &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true; rm -rf "$test_root"' EXIT
sudo service apache2 restart
for attempt in $(seq 1 100); do
if curl --fail --silent http://127.0.0.1/link-field/api/cloud/status | grep -q '"available":true'; then break; fi
sleep 0.1
done
curl --fail --show-error http://127.0.0.1/link-field/
curl --fail --show-error http://127.0.0.1/link-field/api/cloud/status | grep -q '"sharedWorld":true'
test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/server.js)" = 403
test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/package.json)" = 403
test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/scripts/service-control.js)" = 403
test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/.linkfield-deployment.json)" = 403
node scripts/public-smoke-test.js http://127.0.0.1/link-field/

View file

@ -1,22 +1,24 @@
# BEGIN LINKFIELD MANAGED PROXY
ServerSignature Off
<IfModule mod_headers.c>
Header always unset X-Powered-By
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Prefer a native Apache proxy when the host permits it.
<IfModule mod_proxy.c>
<IfModule mod_proxy_wstunnel.c>
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^api/realtime/?$ ws://127.0.0.1:32956/api/realtime [P,L]
</IfModule>
RewriteRule ^api/(.*)$ http://127.0.0.1:32956/api/$1 [P,L]
</IfModule>
# Shared hosts often disable mod_proxy. Route ordinary API requests
# through the bundled PHP bridge instead.
# Safe bootstrap: use the bounded PHP bridge until the Node server
# writes a verified current proxy port after it begins listening.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]
# The service source may share this directory with Apache. Expose only
# the curated browser bundle and deny every other direct file request.
RewriteRule ^$ - [L]
RewriteRule ^(?:index\.html|style\.css|favicon\.(?:svg|ico)|build-meta\.js|runtime-config\.js|shared-contracts\.js|store-catalog\.(?:generated\.js|json)|puzzle-patterns\.js|puzzle-core\.js|app-logic\.js|archive-codec\.js|field-persistence(?:-worker)?\.js|puzzle-worker\.js|app\.js|api-bridge\.php)$ - [L]
RewriteRule ^(?:assets|client)(?:/|$) - [L]
RewriteRule ^ - [F,L]
</IfModule>
<Files ".linkfield-port">
<FilesMatch "(?i)^(?:\.|server\.js$|realtime-server\.js$|package(?:-lock)?\.json$|build-config\.json$|README\.md$)">
Require all denied
</Files>
</FilesMatch>
# END LINKFIELD MANAGED PROXY

View file

@ -12,11 +12,12 @@ npm start
`npm start` は次を自動実行し、完了後すぐにコマンド入力へ戻ります。
1. 旧フォルダから残っているLinkFieldサーバーを停止
2. `$HOME/public_html/link-field` へ公開ファイルを配置
1. 公開ファイルを一時ディレクトリで完成させ、ディレクトリ単位で切り替え
2. 稼働中の管理済みLinkFieldサーバーと旧版サーバーを安全に停止
3. 古いポート情報を削除
4. 単一共有サーバーをバックグラウンド起動
5. 実際の使用ポートを `.linkfield-port` へ保存
4. 自動再起動・ハートビート付きの監視プロセスをバックグラウンド起動
5. ローカルAPIと、設定済みの場合は公開URLの疎通を確認
6. 実際の使用ポートを `.linkfield-port` へ保存
8080番が使用中の場合は空きポートへ自動的に切り替わります。公開側のPHPブリッジは `.linkfield-port` を参照するため、固定ポートは不要です。
@ -44,10 +45,18 @@ LINK_FIELD_PUBLIC_DIR=/実際の公開ディレクトリ npm start
LINK_FIELD_PUBLIC_DIR="$HOME/www/link-field" npm start
```
公開URLも指定すると、起動完了前にページとAPIの外部疎通を検査します。ユーザー名が `333` のホストではREADME記載のURLが自動使用されます。
```bash
LINK_FIELD_PUBLIC_DIR="$HOME/www/link-field" \
LINK_FIELD_PUBLIC_URL="https://example.com/link-field/" \
npm start
```
## 接続確認
```bash
curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'
curl 'https://2012r2.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'
```
正常時は、`available``sharedWorld``singleWorld` がすべて `true` のJSONが返ります。
@ -56,6 +65,44 @@ curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/st
{"available":true,"sharedWorld":true,"singleWorld":true,"realtime":true}
```
### Windows Apache/PHP host hardening
The public `Server` header must not expose exact Apache or PHP versions. Run the helper once without `-Apply` to preview the change, then run it again with `-Apply`. It creates timestamped backups and runs `httpd.exe -t`; if Apache rejects the configuration, both files are restored automatically.
```powershell
powershell -ExecutionPolicy Bypass -File scripts\harden-windows-host.ps1 `
-ApacheConfig C:\Apache24\conf\httpd.conf `
-PhpIni C:\php\php.ini
powershell -ExecutionPolicy Bypass -File scripts\harden-windows-host.ps1 `
-ApacheConfig C:\Apache24\conf\httpd.conf `
-PhpIni C:\php\php.ini `
-Apply
```
Use the actual configuration paths if Apache or PHP is installed elsewhere. Restart Apache after a successful apply, then verify the public deployment:
```bash
npm run smoke:public -- 'https://2012r2.nishi.boats/~333/link-field/'
```
If the project source is installed directly inside `public_html/link-field`, deploy the current hidden `.htaccess` and `server/apache-bridge.js` before restarting Node. LinkField then maintains a strict Apache allowlist automatically. A private source check must return HTTP 403 (404 is also acceptable when the source and web roots are separated):
```bash
curl -I 'https://2012r2.nishi.boats/~333/link-field/server.js'
curl -I 'https://2012r2.nishi.boats/~333/link-field/package.json'
```
If startup reports `Another LinkField server is already running` or asks for legacy-lock recovery, run the verified one-time recovery command and then start again:
```bash
npm run recover
npm start
npm run status
```
Recovery reads only LinkField's configured world/service lock files and refuses to stop a lock-holder unless it is a running Node process. Normal restarts do not require this command.
404の場合は、Apacheの公開先と自動配置先が一致していません。
```bash
@ -69,6 +116,9 @@ ls -l "$HOME/public_html/link-field/api-bridge.php"
npm run status # 状態確認
npm run restart # 再起動
npm stop # 停止
npm run backup # 停止中の手動バックアップ
npm run backups # バックアップ一覧とチェックサム検証
npm run restore -- 2026-08-01T00-00-00-000Z # 停止中に復元
```
ログ確認:
@ -77,6 +127,8 @@ npm stop # 停止
tail -f "$HOME/.local/share/LinkField/service/server.log"
```
ログは10 MiBで自動ローテーションし、5世代を保持します。監視プロセスは5分間に最大10回までクラッシュしたサーバーを再起動します。
前面起動で直接ログを見る場合:
```bash
@ -87,7 +139,7 @@ npm run start:foreground
## データ保存先
共有ワールドは常に次の1か所へ保存されます。
共有ワールドの既定保存先は次の1か所です。
```text
/link-field/world
@ -100,6 +152,14 @@ sudo mkdir -p /link-field/world
sudo chown -R "$(id -un):$(id -gn)" /link-field
```
root直下を利用できない環境では、明示した1か所へ変更できます。全リリースで同じ値を設定してください。
```bash
LINK_FIELD_WORLD_DIR="$HOME/.local/share/LinkField/world" npm start
```
自動バックアップは既定で `/link-field/backups`(変更時はワールドディレクトリの隣の `backups`へ1日1回作成され、チェックサム付きで7世代保持されます。保存先は `LINK_FIELD_BACKUP_DIR` で変更できます。
展開フォルダやブラウザごとに別ワールドは作成されません。通常ウィンドウとシークレットウィンドウも同じサーバー正本を取得します。
## 盤面共有
@ -107,7 +167,7 @@ sudo chown -R "$(id -un):$(id -gn)" /link-field
未クリア盤面の線・特殊マス進行もサーバーへ保存されます。盤面を操作する際は専用の共有APIで占有権を即時取得し、利用できない場合だけリアルタイム経路へフォールバックします。占有応答前に指を離しても、ドラッグ軌跡は承認後に適用されます。接続状態は左下のFPS表示直上にある固定サイズの小型インジケーターで確認できます。
## 更新時の起動
`npm start` は、稼働中の旧LinkFieldサーバーを停止してから、展開した版で起動し直します。新しい画面と古いサーバーが混在することはありません
`npm start` は、新しい公開ツリーを完成・検証してからアトミックに切り替え、旧LinkFieldサーバーを停止して監視プロセス配下で起動し直します。配置に失敗した場合は旧公開ツリーが維持されます
```bash
npm start
@ -115,6 +175,25 @@ npm start
起動エラー画面の「再試行」は共有サーバーへの再接続を行います。ブラウザ内バックアップの復元は実行しません。
## Apache・PHPの要件
- 推奨: `mod_rewrite``mod_proxy``mod_proxy_http``mod_proxy_wstunnel``mod_headers`
- HTTPプロキシが無い場合はPHPブリッジへ自動フォールバックします。
- `ServerTokens Prod``ServerSignature Off` をホスト設定で有効にし、Apache・PHP・OpenSSLをサポート中の版へ更新してください。
- `.htaccess` を有効にする公開ディレクトリには `AllowOverride All`または必要なRewrite/Header権限が必要です。
リリース後の公開確認:
```bash
LINK_FIELD_PUBLIC_URL="https://example.com/link-field/" npm run smoke:public
```
PHP実装を含むブリッジ統合テスト
```bash
npm run test:bridge
```
## v48.0の共有確定ルール

View file

@ -9,6 +9,55 @@ function fail_json(int $status, string $message): never {
exit;
}
function write_all($socket, string $value): void {
$written = 0;
$length = strlen($value);
while ($written < $length) {
$result = fwrite($socket, substr($value, $written, 64 * 1024));
if ($result === false || $result === 0) {
fclose($socket);
fail_json(502, 'Failed to send the request to the LinkField server.');
}
$written += $result;
}
}
function request_body(int $maximum): array {
$input = fopen('php://input', 'rb');
if (!is_resource($input)) fail_json(400, 'Could not read the request body.');
$declared = trim((string) ($_SERVER['CONTENT_LENGTH'] ?? ''));
if ($declared !== '') {
if (!preg_match('/^[0-9]+$/D', $declared)) { fclose($input); fail_json(400, 'Invalid request content length.'); }
$length = (int) $declared;
if ($length > $maximum) { fclose($input); fail_json(413, 'Request body is too large.'); }
return [$input, $length];
}
$temporary = fopen('php://temp/maxmemory:1048576', 'w+b');
if (!is_resource($temporary)) { fclose($input); fail_json(500, 'Could not stage the request body.'); }
$length = 0;
while (!feof($input)) {
$chunk = fread($input, 64 * 1024);
if ($chunk === false) { fclose($input); fclose($temporary); fail_json(400, 'Could not read the request body.'); }
$length += strlen($chunk);
if ($length > $maximum) { fclose($input); fclose($temporary); fail_json(413, 'Request body is too large.'); }
if ($chunk !== '' && fwrite($temporary, $chunk) !== strlen($chunk)) { fclose($input); fclose($temporary); fail_json(500, 'Could not stage the request body.'); }
}
fclose($input);
rewind($temporary);
return [$temporary, $length];
}
function write_body($socket, $body, int $length): void {
$written = 0;
while ($written < $length) {
$chunk = fread($body, min(64 * 1024, $length - $written));
if ($chunk === false || $chunk === '') { fclose($body); fclose($socket); fail_json(400, 'Request body ended before its declared length.'); }
write_all($socket, $chunk);
$written += strlen($chunk);
}
fclose($body);
}
$portFile = __DIR__ . DIRECTORY_SEPARATOR . '.linkfield-port';
if (!is_file($portFile)) {
fail_json(503, 'LinkField server is not running. Run npm start in this directory.');
@ -20,7 +69,7 @@ if ($port === false) {
}
$targetPath = isset($_GET['path']) ? (string) $_GET['path'] : '';
if (!preg_match('#^/api/(?:cloud|player|realtime)(?:/|$)#', $targetPath)) {
if (!preg_match('#^/api/(?:cloud|player|realtime)(?:/[A-Za-z0-9_-]+)*$#D', $targetPath)) {
fail_json(400, 'Invalid LinkField API path.');
}
$query = $_GET;
@ -33,11 +82,7 @@ $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, ['GET', 'POST', 'HEAD'], true)) {
fail_json(405, 'Method not allowed.');
}
$body = file_get_contents('php://input');
if ($body === false) $body = '';
if (strlen($body) > 64 * 1024 * 1024) {
fail_json(413, 'Request body is too large.');
}
[$body, $bodyLength] = request_body(8 * 1024 * 1024);
$authorization = '';
if (function_exists('getallheaders')) {
@ -63,7 +108,7 @@ $socket = @stream_socket_client(
if (!is_resource($socket)) {
fail_json(502, 'LinkField server is not reachable on its local port. Restart npm start.');
}
stream_set_timeout($socket, 15);
stream_set_timeout($socket, 30);
$requestHeaders = [
$method . ' ' . $targetPath . " HTTP/1.1",
@ -71,29 +116,27 @@ $requestHeaders = [
'Connection: close',
'Accept: application/json',
'Content-Type: application/json',
'Content-Length: ' . strlen($body),
'Content-Length: ' . $bodyLength,
];
if ($authorization !== '') $requestHeaders[] = 'Authorization: ' . str_replace(["\r", "\n"], '', $authorization);
$request = implode("\r\n", $requestHeaders) . "\r\n\r\n" . $body;
$written = 0;
$length = strlen($request);
while ($written < $length) {
$result = fwrite($socket, substr($request, $written));
if ($result === false || $result === 0) {
fclose($socket);
fail_json(502, 'Failed to send the request to the LinkField server.');
}
$written += $result;
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
if (preg_match('/^[0-9A-Fa-f:.]{3,64}$/', $remoteAddress)) $requestHeaders[] = 'X-Forwarded-For: ' . $remoteAddress;
write_all($socket, implode("\r\n", $requestHeaders) . "\r\n\r\n");
write_body($socket, $body, $bodyLength);
$response = '';
$separator = false;
while (!feof($socket) && $separator === false && strlen($response) <= 64 * 1024) {
$chunk = fread($socket, 8192);
if ($chunk === false) break;
$response .= $chunk;
$separator = strpos($response, "\r\n\r\n");
}
$response = stream_get_contents($socket);
$meta = stream_get_meta_data($socket);
fclose($socket);
if ($response === false || $response === '' || !empty($meta['timed_out'])) {
if ($response === '' || $separator === false || !empty($meta['timed_out'])) {
fclose($socket);
fail_json(504, 'LinkField server did not respond in time.');
}
$separator = strpos($response, "\r\n\r\n");
if ($separator === false) fail_json(502, 'Invalid response from the LinkField server.');
$headerText = substr($response, 0, $separator);
$responseBody = substr($response, $separator + 4);
$headerLines = explode("\r\n", $headerText);
@ -107,9 +150,19 @@ foreach ($headerLines as $line) {
if ($colon === false) continue;
$name = trim(substr($line, 0, $colon));
$value = trim(substr($line, $colon + 1));
if (in_array(strtolower($name), ['content-type', 'cache-control', 'x-content-type-options'], true)) {
if (in_array(strtolower($name), ['content-type', 'cache-control', 'x-content-type-options', 'retry-after'], true)) {
header($name . ': ' . $value, true);
}
}
header('X-LinkField-Bridge: php', true);
if ($method !== 'HEAD') echo $responseBody;
header_remove('X-Powered-By');
if ($method !== 'HEAD') {
echo $responseBody;
while (!feof($socket)) {
$chunk = fread($socket, 64 * 1024);
if ($chunk === false) break;
echo $chunk;
if (function_exists('flush')) flush();
}
}
fclose($socket);

View file

@ -8,6 +8,7 @@
const key2=shared.key2;
function sectionCountRange(level){
const max=Math.max(1,Math.min(10,Math.round(level)||1));
if(max<=3)return{min:1,max:1};
return{min:Math.max(1,max-3),max};
}
function normalizeGeneratedShape(chunks){

100
app.js
View file

@ -32,7 +32,7 @@ const appVersionLabel=`v${APP_VERSION}`,brandVersion=document.querySelector('.br
if(brandVersion)brandVersion.textContent=appVersionLabel;
if(!document.title.endsWith(appVersionLabel))document.title=`${document.title.replace(/\s+v[\w.-]+$/,'').trim()} ${appVersionLabel}`;
const MAX_BOARDS=200000,MAX_PATHS_PER_BOARD=512,MAX_IMPORT_BYTES=100*1024*1024,CLOCK_DRIFT_LIMIT=5*60*1000,LOCAL_MIRROR_MAX_BYTES=4.5*1024*1024,IDB_STARTUP_TIMEOUT=8000,MAX_FRONTIER_GENERATION_CYCLES=3,GC_BATCH_ROWS=500;
const MAX_RENDER_FPS=30,GLOBAL_FRAME_INTERVAL=1000/MAX_RENDER_FPS,AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,REACTION_TARGET_FPS=30,REACTION_FRAME_INTERVAL=1000/REACTION_TARGET_FPS,DRAG_TARGET_FPS=30,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=34,CAMERA_DISPLAY_WATCHDOG_MS=34,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_LIVE_CATCHUP_CELLS=6,DRAG_MAX_RELEASE_CATCHUP_CELLS=24,LIGHTWEIGHT_DRAG_BOARD_CELLS=120,GENERATION_FAILURE_BONUS=2500,GENERATION_FAILURE_MIN_MS=8000,SHARED_EXPANSION_GRACE_MS=60000,SHARED_EXPANSION_JITTER_MS=30000,REALTIME_CURSOR_INTERVAL=50,REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000,REALTIME_VIEWPORT_INTERVAL=250,REALTIME_PLAYER_STALE_MS=15000,REALTIME_CLAIM_REQUEST_TIMEOUT=5000,REALTIME_CLAIM_TOUCH_INTERVAL=20000,REALTIME_REACTION_DURATION=4500,REALTIME_REACTION_RATE_INTERVAL=500,REACTION_LONG_PRESS_MS=500,REACTION_MOVE_CANCEL_PX=18;
const MAX_RENDER_FPS=30,GLOBAL_FRAME_INTERVAL=1000/MAX_RENDER_FPS,AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,REACTION_TARGET_FPS=30,REACTION_FRAME_INTERVAL=1000/REACTION_TARGET_FPS,DRAG_TARGET_FPS=30,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=34,CAMERA_DISPLAY_WATCHDOG_MS=34,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_LIVE_CATCHUP_CELLS=6,DRAG_MAX_RELEASE_CATCHUP_CELLS=24,LIGHTWEIGHT_DRAG_BOARD_CELLS=120,GENERATION_FAILURE_BONUS=2500,GENERATION_FAILURE_MIN_MS=8000,SHARED_EXPANSION_GRACE_MS=60000,SHARED_EXPANSION_JITTER_MS=30000,REALTIME_CURSOR_INTERVAL=50,PHP_REALTIME_CURSOR_INTERVAL=250,PHP_REALTIME_POLL_INTERVAL=500,REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000,REALTIME_VIEWPORT_INTERVAL=250,REALTIME_PLAYER_STALE_MS=15000,REALTIME_CLAIM_REQUEST_TIMEOUT=5000,REALTIME_CLAIM_TOUCH_INTERVAL=20000,REALTIME_REACTION_DURATION=4500,REALTIME_REACTION_RATE_INTERVAL=500,REACTION_LONG_PRESS_MS=500,REACTION_MOVE_CANCEL_PX=18;
const INTERACTION_SCHEDULER_VARIANT=(()=>{try{return localStorage.getItem('bend-field-interaction-scheduler-variant')==='reduced'?'reduced':'full'}catch(_){return'full'}})();
globalThis.BEND_INTERACTION_SCHEDULER=Object.freeze({enabled:true,version:2,variant:INTERACTION_SCHEDULER_VARIANT});
const LEGACY_LOCAL_SOLVER='\u3042\u306a\u305f',DEFAULT_PLAYER_NAME='\u65c5\u4eba';
@ -230,7 +230,7 @@ const storageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:compact`,
worldEpochStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:epoch`,
worldDbName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
worldLockName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
CHAIN_WINDOW=120000,SAVE_DELAY=180;
CHAIN_WINDOW=120000,SAVE_DELAY=90;
const mirrorChunkPrefix=`${storageKey}:chunk:`;
const MIRROR_IDLE_DELAY=5000,MIRROR_IDLE_TIMEOUT=15000,MIRROR_CHUNK_BYTES=64*1024,MIRROR_CHUNK_FORMAT='bend-field-chunked-v1',MINIMAP_CACHE_OVERSCAN_CHUNKS=4,LOD_CHANGES_PER_PASS=1,BOARD_RENDERS_PER_FRAME=6,HYDRATE_CONCURRENCY=4;
const RETIRED_WORLD_STORES=Object.freeze([
@ -247,8 +247,8 @@ const timeAttackSuggestion=document.querySelector('#timeAttackSuggestion');
const runtimeConfig=globalThis.BendRuntimeConfig||Object.freeze({}),cloudApiEnabled=runtimeConfig.cloudApi===true,
cloudAppBaseUrl=(()=>{try{return new URL(runtimeConfig.appBaseUrl||'./',document.baseURI).href}catch(_){return document.baseURI}})(),
cloudApiBridgeUrl=(()=>{try{return runtimeConfig.apiBridgeUrl?new URL(runtimeConfig.apiBridgeUrl,cloudAppBaseUrl).href:''}catch(_){return''}})(),
cloudApiBaseCandidates=(()=>{const values=[];for(const candidate of[cloudApiBridgeUrl,`${cloudAppBaseUrl}api/`,(()=>{try{return new URL('/api/',location.href).href}catch(_){return'/api/'}})()])if(candidate&&!values.includes(candidate))values.push(candidate);return Object.freeze(values)})();
let cloudApiBaseUrl=cloudApiBaseCandidates[0]||'/api/';
cloudApiBaseCandidates=(()=>{const values=[];for(const candidate of[`${cloudAppBaseUrl}api/`,cloudApiBridgeUrl,(()=>{try{return new URL('/api/',location.href).href}catch(_){return'/api/'}})()])if(candidate&&!values.includes(candidate))values.push(candidate);return Object.freeze(values)})();
let cloudApiBaseUrl=cloudApiBaseCandidates[0]||'/api/',cloudApiPhpBridgeActive=false;
function cloudEndpointUrl(value,baseUrl=cloudApiBaseUrl){
if(typeof value!=='string'||!/^\/?api(?:\/|$)/.test(value))return value;
if(typeof cloudApiBridgeUrl!=='undefined'&&cloudApiBridgeUrl&&baseUrl===cloudApiBridgeUrl){
@ -256,7 +256,7 @@ function cloudEndpointUrl(value,baseUrl=cloudApiBaseUrl){
}
const relative=value.replace(/^\/?api\/?/,'');try{return new URL(relative,baseUrl).href}catch(_){return`/api/${relative}`}
}
function cloudApiUsesPhpBridge(){return Boolean(cloudApiBridgeUrl&&cloudApiBaseUrl===cloudApiBridgeUrl)}
function cloudApiUsesPhpBridge(){return cloudApiPhpBridgeActive||Boolean(cloudApiBridgeUrl&&cloudApiBaseUrl===cloudApiBridgeUrl)}
const sessionId=globalThis.crypto?.randomUUID?.()||`session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sessionRecoveryJournalKey=recoveryJournalPrefix+sessionId;
const loadNotices=[];
@ -2591,6 +2591,7 @@ async function placeChildAtFrontierAttempt(source,frontier,attemptBase=0,{prepar
const intrinsicLevel=solverDifficulty(puzzle,targetLevel),intrinsicInBand=difficultyFitsRegion(intrinsicLevel,targetLevel);
const level=intrinsicLevel,inBand=intrinsicInBand;
puzzle.difficulty=level;puzzle.level=level;puzzle.regionalTarget=targetLevel;
if(!issue&&level<=3&&shape.length!==1)issue='low-level board must use one section';
if(!issue&&!inBand&&!allowRegionalFallback)issue='\u5730\u57df\u96e3\u6613\u5ea6\u7bc4\u56f2\u5916';
const burden=AppLogic.interactionBurden(puzzle);puzzle.interactionBurden=burden;
if(!issue&&burden.score>=39)issue='\u64cd\u4f5c\u8ca0\u62c5\u304c\u4e0a\u9650\u3092\u8d85\u904e';
@ -3831,7 +3832,7 @@ async function checkSolvedAndExpand(b){
}
if(cloudAvailable&&data.cloudProfile){
let published=false;
for(let attempt=0;attempt<3&&!published;attempt++){published=await pushCloudPending();if(!published&&metaState(b.id)?.solved===true)await sleep(180*(attempt+1))}
for(let attempt=0;attempt<3&&!published;attempt++){published=await pushCloudPending(b.id);if(!published&&metaState(b.id)?.solved===true)await sleep(180*(attempt+1))}
if(!published){
finishCompletionVisual(b.id,true);
if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=previous.state;normalizedStateObjects.add(previous.state);data.lastSolveAt=previous.lastSolveAt;data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;data.specialMechanicsSeen=previous.specialMechanicsSeen;b.meta.sealedSides=previous.sealedSides;b.meta.rev=previous.metaRev;markStateDirty(b.id);markGlobalDirty()}
@ -4461,6 +4462,10 @@ function clearPendingClaimPointer(b,pointerId,{release=true,releaseGesture=relea
if(releaseGesture)gestureCoordinator.release(pending.pointerId,'draw');
refreshInteractionState();return pending;
}
function recoverLostBoardPointerCapture(b,event){
const pointerId=event?.pointerId,drawing=b?.drawing;if(pointerId==null||drawing?.pointerId!==pointerId||!realtimeHeldPointers.has(pointerId))return false;
requestAnimationFrame(()=>{if(b.card?.isConnected&&b.drawing?.pointerId===pointerId&&realtimeHeldPointers.has(pointerId))safeCapture(b.svg,pointerId)});return true;
}
function activateBoardPointerDrag(b,pending,point){
const started=perfStart();
const drawing=b?.drawing;if(!drawing||!pending||!point){perfEnd('pickupPointerDownCommit',started);return false}
@ -4477,8 +4482,8 @@ function activateBoardPointerDrag(b,pending,point){
function bindBoard(b){
b.card.addEventListener('focus',()=>selectBoard(b));
b.svg.addEventListener('pointerdown',async e=>{
const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit');
if(e.button!==0||b.joiningPaths||!endpointTarget&&!gateTarget||!gestureCoordinator.claim(e.pointerId,'draw'))return;
const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit'),boardTarget=e.target.closest?.('.board-input-surface');
if(e.button!==0||b.joiningPaths||!endpointTarget&&!gateTarget&&!boardTarget||!gestureCoordinator.claim(e.pointerId,'draw'))return;
e.preventDefault();
e.stopPropagation();
selectBoard(b,{paint:false});
@ -4558,7 +4563,7 @@ function bindBoard(b){
};
b.svg.addEventListener('pointerup',e=>finishPointer(e,true));
b.svg.addEventListener('pointercancel',e=>finishPointer(e,false));
b.svg.addEventListener('lostpointercapture',e=>{if(b.pendingClaimPointer?.pointerId===e.pointerId){if(!b.pendingClaimPointer.released)clearPendingClaimPointer(b,e.pointerId,{release:false,releaseGesture:true})}else if(b.releaseDrain?.pointerId===e.pointerId)return;else if(b.drawing?.pointerId===e.pointerId)finishPointer(e,false);else{gestureCoordinator.release(e.pointerId,'draw','lost-capture');refreshInteractionState()}});
b.svg.addEventListener('lostpointercapture',e=>{if(b.pendingClaimPointer?.pointerId===e.pointerId){if(!b.pendingClaimPointer.released)clearPendingClaimPointer(b,e.pointerId,{release:false,releaseGesture:true})}else if(b.releaseDrain?.pointerId===e.pointerId)return;else if(b.drawing?.pointerId===e.pointerId){if(!recoverLostBoardPointerCapture(b,e))finishPointer(e,false)}else{gestureCoordinator.release(e.pointerId,'draw','lost-capture');refreshInteractionState()}});
b.svg.addEventListener('contextmenu',e=>e.preventDefault());
b.svg.addEventListener('focusin',()=>selectBoard(b));
b.svg.addEventListener('keydown',async e=>{
@ -5281,7 +5286,7 @@ function timeAttackResultText(result=data.lastTimeAttack){
'⏱️ LinkFieldリンクフィールドタイムアタック',
`🏁 ${result.durationMinutes}分 🧩 ${result.solves}枚クリア`,
`🏆 合計 ${formatScore(result.total)}`,
'https://host.nishi.boats/~333/link-field/'
cloudAppBaseUrl
].join('\n');
}
function timeAttackCooldownRemaining(_durationMinutes,now=trustedNow()){
@ -5943,13 +5948,13 @@ window.addEventListener('pagehide',()=>{skipCompletionVisuals();cleanupGemEffect
document.addEventListener('visibilitychange',()=>{const hidden=document.visibilityState==='hidden';document.body.classList.toggle('effects-paused',hidden);if(hidden){cancelReactionGesture();hideRealtimeCursor();cancelReactionRenderScheduler(true);stopAuroraRgbAnimation();clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;clearTimeout(realtimePollTimer);realtimePollTimer=0;pauseNoiseBackground();checkpointForLifecycle();void pushCloudPending()}else{scheduleNoiseBackground(true);resumeTimeAttackTimer();connectRealtime();scheduleRealtimeViewport(true);scheduleRealtimeHeartbeat();schedulePresenceRender(true);updateAuroraAnimationState();scheduleReactionRender();void pullCloudWorld();scheduleMirrorCheckpoint()}});
const cloudBtn=document.querySelector('#cloudBtn');
function emptyCloudPending(){return{metaIds:new Set(),stateIds:new Set(),deleted:new Set(),globalChanged:false}}
let cloudAvailable=false,cloudPushTimer=null,cloudPushPending=emptyCloudPending(),cloudSyncing=false,cloudCheckpointRetryTimer=0,worldPollTimer=0,lastCloudWorldGlobalSignature='';
let cloudAvailable=false,cloudPushTimer=null,cloudPushPending=emptyCloudPending(),cloudSyncing=false,cloudCheckpointRetryTimer=0,worldPollTimer=0,remoteWorldPullTimer=0,remoteWorldRevision=0,lastCloudWorldGlobalSignature='';
const remotePlayers=new Map(),boardClaims=new Map(),realtimeClaimRequests=new Map(),remoteCursorImageCache=new Map(),realtimeHeldPointers=new Set(),realtimeReactions=new Map(),pendingRealtimeReactionMessages=[];
let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimePollTimer=0,realtimePollSequence=0,realtimeTransport='none',realtimeHttpConnecting=false,realtimeHttpQueue=Promise.resolve(),realtimeLastCursorSentAt=0,realtimeLastCursorSampleAt=0,realtimePendingCursor=null,realtimePendingCursorClient=null,realtimeRequestSequence=0,realtimeOwnClaimBoardId=null,realtimeLastClaimTouchAt=0,presenceFrame=0,presenceDelayTimer=0,presenceLastDraw=0,presenceDirty=true,presenceLastTimestamp=0,presenceCameraSnapshot=null,reactionFrame=0,reactionDelayTimer=0,reactionWatchdogTimer=0,reactionCalibrationFrame=0,reactionVsyncInterval=1000/60,reactionVsyncCalibrated=false,reactionLastDraw=0,reactionLastMetricFrame=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0;
let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimePollTimer=0,realtimePollSequence=0,realtimeTransport='none',realtimeHttpConnecting=false,realtimeHttpQueue=Promise.resolve(),realtimeWebSocketFailures=0,realtimeLastCursorSentAt=0,realtimeLastCursorSampleAt=0,realtimePendingCursor=null,realtimePendingCursorClient=null,realtimeRequestSequence=0,realtimeOwnClaimBoardId=null,realtimeLastClaimTouchAt=0,presenceFrame=0,presenceDelayTimer=0,presenceLastDraw=0,presenceDirty=true,presenceLastTimestamp=0,presenceCameraSnapshot=null,reactionFrame=0,reactionDelayTimer=0,reactionWatchdogTimer=0,reactionCalibrationFrame=0,reactionVsyncInterval=1000/60,reactionVsyncCalibrated=false,reactionLastDraw=0,reactionLastMetricFrame=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0;
function realtimeWebSocketUrl(){const endpoint=new URL(cloudEndpointUrl('/api/realtime'));endpoint.protocol=endpoint.protocol==='https:'?'wss:':'ws:';return endpoint.href}
function enqueueRealtimeHttp(task){const run=realtimeHttpQueue.then(task,task);realtimeHttpQueue=run.then(()=>undefined,()=>undefined);return run}
function applyRealtimeHttpEnvelope(envelope){if(!envelope||typeof envelope!=='object')return false;if(Number.isFinite(envelope.serverTime))serverClockOffset=envelope.serverTime-Date.now();if(Number.isFinite(envelope.sequence))realtimePollSequence=Math.max(realtimePollSequence,Number(envelope.sequence));for(const message of Array.isArray(envelope.messages)?envelope.messages:[])handleRealtimeMessage(message);return true}
function scheduleRealtimeHttpPoll(delay=450){clearTimeout(realtimePollTimer);realtimePollTimer=0;if(realtimeTransport!=='http-poll'||!realtimePresenceId||document.visibilityState==='hidden')return false;realtimePollTimer=setTimeout(()=>{realtimePollTimer=0;void enqueueRealtimeHttp(async()=>{try{const query=new URLSearchParams({presenceId:realtimePresenceId,after:String(realtimePollSequence)}),result=await fetchJson(`/api/realtime/poll?${query}`,{headers:cloudAuthHeaders()},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(350)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}})},Math.max(100,delay));return true}
function applyRealtimeHttpEnvelope(envelope){if(!envelope||typeof envelope!=='object')return false;if(Number.isFinite(envelope.serverTime))serverClockOffset=envelope.serverTime-Date.now();if(Number.isFinite(envelope.sequence))realtimePollSequence=Math.max(0,Number(envelope.sequence));for(const message of Array.isArray(envelope.messages)?envelope.messages:[])handleRealtimeMessage(message);if(envelope.eventGap===true&&realtimeReady)realtimeSend({type:'snapshot-request'});return true}
function scheduleRealtimeHttpPoll(delay=cloudApiUsesPhpBridge()?PHP_REALTIME_POLL_INTERVAL:50){clearTimeout(realtimePollTimer);realtimePollTimer=0;if(realtimeTransport!=='http-poll'||!realtimePresenceId||document.visibilityState==='hidden')return false;realtimePollTimer=setTimeout(()=>{realtimePollTimer=0;void enqueueRealtimeHttp(async()=>{try{const bridge=cloudApiUsesPhpBridge(),query=new URLSearchParams({presenceId:realtimePresenceId,after:String(realtimePollSequence),wait:bridge?'0':'20000'}),result=await fetchJson(`/api/realtime/poll?${query}`,{headers:cloudAuthHeaders()},bridge?5000:25000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(bridge?PHP_REALTIME_POLL_INTERVAL:50)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}})},Math.max(25,delay));return true}
function realtimeSend(message){
if(!realtimeReady)return false;
if(realtimeTransport==='http-poll'){
@ -5981,7 +5986,7 @@ function scheduleRealtimeHeartbeat(){clearTimeout(realtimeHeartbeatTimer);realti
function queueRealtimeCursor(event){
if(!realtimeReady||event.pointerType==='touch'||document.body.classList.contains('is-drawing')||!viewport.contains(event.target))return;
realtimePendingCursorClient={clientX:event.clientX,clientY:event.clientY,inputAt:Number(event.timeStamp)||perfNow()};
const wait=REALTIME_CURSOR_INTERVAL-(perfNow()-realtimeLastCursorSentAt);if(realtimeCursorTimer)return;
const transportInterval=realtimeTransport==='http-poll'?(cloudApiUsesPhpBridge()?PHP_REALTIME_CURSOR_INTERVAL:250):REALTIME_CURSOR_INTERVAL,wait=transportInterval-(perfNow()-realtimeLastCursorSentAt);if(realtimeCursorTimer)return;
realtimeCursorTimer=setTimeout(()=>{
realtimeCursorTimer=0;const client=realtimePendingCursorClient;if(!client||!realtimeReady)return;
const point=worldUnitAtClient(client.clientX,client.clientY),previous=realtimePendingCursor,now=perfNow(),elapsed=Math.max(1,now-(realtimeLastCursorSampleAt||now)),
@ -6285,24 +6290,38 @@ function refreshClaimPresentation(boardId=null){
const ids=boardId?[boardId]:rendered.keys();for(const id of ids){const board=rendered.get(id);if(board)applyClaimPresentationToBoard(board)}
}
function applyClaim(raw){if(!raw?.boardId)return;if(metaState(raw.boardId)?.solved===true){removeClaim(raw.boardId,'cleared');return}boardClaims.set(raw.boardId,{...raw,expiresAt:Number(raw.expiresAt)||trustedNow()});if(raw.playerId===currentPlayerId())realtimeOwnClaimBoardId=raw.boardId;refreshClaimPresentation(raw.boardId)}
function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);const board=rendered.get(boardId);if(claim?.playerId===currentPlayerId()&&board?.drawing?.pointerId!=null&&reason!=='cleared')cancelPointerGestures()}
function preserveActiveDrawingClaim(boardId,claim,reason){
const board=rendered.get(boardId),pointerId=board?.drawing?.pointerId;if(reason==='cleared'||claim?.playerId!==currentPlayerId()||pointerId==null||!realtimeHeldPointers.has(pointerId))return false;
claim.expiresAt=Math.max(Number(claim.expiresAt)||0,trustedNow()+30000);boardClaims.set(boardId,claim);realtimeOwnClaimBoardId=boardId;refreshClaimPresentation(boardId);
void requestBoardClaim(boardId,{force:true}).then(ok=>{if(!ok&&board?.drawing?.pointerId===pointerId)console.warn(`LinkField: board claim recovery is pending for ${boardId}`)});return true;
}
function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);if(preserveActiveDrawingClaim(boardId,claim,reason))return false;boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);return true}
function applyRealtimeSnapshot(message){
const seenPlayers=new Set();for(const player of message.players||[]){seenPlayers.add(player.presenceId);applyRemotePlayer(player)}for(const id of[...remotePlayers.keys()])if(!seenPlayers.has(id))remotePlayers.delete(id);
const seenClaims=new Set();for(const claim of message.claims||[]){seenClaims.add(claim.boardId);applyClaim(claim)}for(const id of[...boardClaims.keys()])if(!seenClaims.has(id))removeClaim(id,'viewport');for(const reaction of message.reactions||[])applyRealtimeReaction(reaction);schedulePresenceRender(true);refreshClaimPresentation();
}
function applyRealtimeWorldDelta(message){
const revision=Math.max(0,Number(message?.revision)||0),page=message?.page;if(!revision||revision<=(data.cloudRevision||0)||!page||!isPlainObject(page.metas)||!isPlainObject(page.states))return false;
const external={...(page.global||{}),worldEpoch:data.worldEpoch,metas:{},states:{}};
for(const[id,raw]of Object.entries(page.metas)){const meta=normalizeMeta(id,raw);if(meta)external.metas[id]=meta}
for(const[id,raw]of Object.entries(page.states)){const board=rendered.get(id),locallyOwned=boardClaimOwnedByMe(id)||board?.drawing?.pointerId!=null||board?.drawing?.keyboardActive;if(!locallyOwned)external.states[id]=normalizeState(raw)}
cloudApplyingRemote=true;try{mergeSnapshotIntoData(external,{finalize:false,authoritativeWorld:false});for(const id of Object.keys(external.metas))dirtyMetaIds.add(id);for(const id of Object.keys(external.states))dirtyStateIds.add(id);resolveMergedOverlaps();data.cloudRevision=revision;remoteWorldRevision=Math.max(remoteWorldRevision,revision);statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}
void refreshWorldView({rebuild:true,syncConnections:true,persist:false});void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()});return true;
}
function settleRealtimeClaimResult(message){const pending=realtimeClaimRequests.get(message.requestId);if(!pending)return;if(message.claim)applyClaim(message.claim);pending.resolve(message)}
function handleRealtimeMessage(message){
if(!message||typeof message!=='object')return;
if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;setCloudStatus();realtimeClaimTtlMs=Math.max(1000,Number(message.claimTtlMs)||realtimeClaimTtlMs);serverClockOffset=Number.isFinite(message.serverTime)?message.serverTime-Date.now():serverClockOffset;scheduleRealtimeViewport(true);if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor});flushPendingRealtimeReactions();scheduleRealtimeHeartbeat();return}
if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;if(realtimeTransport==='websocket')realtimeWebSocketFailures=0;setCloudStatus();realtimeClaimTtlMs=Math.max(1000,Number(message.claimTtlMs)||realtimeClaimTtlMs);serverClockOffset=Number.isFinite(message.serverTime)?message.serverTime-Date.now():serverClockOffset;scheduleRealtimeViewport(true);if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor});flushPendingRealtimeReactions();scheduleRealtimeHeartbeat();if(realtimeOwnClaimBoardId&&rendered.get(realtimeOwnClaimBoardId)?.drawing?.pointerId!=null)void requestBoardClaim(realtimeOwnClaimBoardId,{force:true});return}
if(message.type==='snapshot')return applyRealtimeSnapshot(message);
if(message.type==='cursor')return applyRemotePlayer(message);
if(message.type==='player-left'){remotePlayers.delete(message.presenceId);schedulePresenceRender(true);scheduleMinimap(true);return}
if(message.type==='claim')return applyClaim(message.claim);
if(message.type==='claim-release')return removeClaim(message.boardId,message.reason);
if(message.type==='claim-release'){removeClaim(message.boardId,message.reason);if(message.reason!=='moved')void pullCloudWorld();return}
if(message.type==='claim-result')return settleRealtimeClaimResult(message);
if(message.type==='reaction')return applyRealtimeReaction(message.reaction);
if(message.type==='player-profile'){for(const player of remotePlayers.values())if(player.playerId===message.playerId)player.name=String(message.name||player.name).slice(0,24);for(const claim of boardClaims.values())if(claim.playerId===message.playerId)claim.playerName=String(message.name||claim.playerName).slice(0,24);refreshClaimPresentation();schedulePresenceRender(true);return}
if(message.type==='board-cleared'&&message.event){const revision=Number(message.event.revision)||0;if(revision>(data.worldFeedRevision||0)){data.worldFeedRevision=revision;markGlobalDirty(false);addClearFeedEvent(message.event)}removeClaim(message.event.id,'cleared');void pullCloudWorld();}
if(message.type==='world-revision'){if(!applyRealtimeWorldDelta(message))scheduleRemoteWorldPull(Number(message.revision)||0);return}
}
function rejectRealtimeClaims(){for(const pending of realtimeClaimRequests.values()){clearTimeout(pending.timer);pending.resolve(false)}realtimeClaimRequests.clear()}
function scheduleRealtimeReconnect(delay=1500){clearTimeout(realtimeReconnectTimer);if(!cloudAvailable||!data.cloudProfile||document.visibilityState==='hidden')return;realtimeReconnectTimer=setTimeout(connectRealtime,delay)}
@ -6313,12 +6332,12 @@ function connectRealtimeHttp(){
}
function connectRealtime(){
clearTimeout(realtimeReconnectTimer);realtimeReconnectTimer=0;if(!cloudApiEnabled||!cloudAvailable||!data.cloudProfile)return false;
if(cloudApiUsesPhpBridge()||runtimeConfig.realtimeTransport==='http-poll'||typeof WebSocket==='undefined')return connectRealtimeHttp();
if(cloudApiUsesPhpBridge()||runtimeConfig.realtimeTransport==='http-poll'||typeof WebSocket==='undefined'||realtimeWebSocketFailures>=2)return connectRealtimeHttp();
if(realtimeSocket&&[WebSocket.OPEN,WebSocket.CONNECTING].includes(realtimeSocket.readyState))return true;
const socket=new WebSocket(realtimeWebSocketUrl());realtimeSocket=socket;realtimeTransport='websocket';realtimeReady=false;
socket.onopen=()=>{if(socket!==realtimeSocket)return;socket.send(JSON.stringify({type:'hello',playerId:data.cloudProfile.playerId,token:data.cloudProfile.token}))};
socket.onmessage=event=>{if(socket!==realtimeSocket)return;try{handleRealtimeMessage(JSON.parse(event.data))}catch(error){console.warn('BEND FIELD: realtime message failed',error)}};
socket.onerror=()=>{};socket.onclose=()=>{if(socket!==realtimeSocket)return;resetRealtimeConnectionState();scheduleRealtimeReconnect()};return true;
socket.onerror=()=>{};socket.onclose=()=>{if(socket!==realtimeSocket)return;realtimeWebSocketFailures++;resetRealtimeConnectionState();scheduleRealtimeReconnect(realtimeWebSocketFailures>=2?100:1500)};return true;
}
const directBoardClaimRequests=new Map();
function requestBoardClaimThroughRealtime(boardId,timeout=2200){
@ -6330,11 +6349,11 @@ function requestBoardClaimThroughRealtime(boardId,timeout=2200){
if(!realtimeSend({type:'claim',requestId,boardId}))finish(null);
});
}
async function requestBoardClaim(boardId){
if(metaState(boardId)?.solved||!cloudAvailable||!data.cloudProfile)return false;
async function requestBoardClaim(boardId,{allowLocalSolved=false,force=false}={}){
if(!allowLocalSolved&&metaState(boardId)?.solved||!cloudAvailable||!data.cloudProfile)return false;
const existing=currentBoardClaim(boardId),now=trustedNow();
if(existing?.playerId!==currentPlayerId()&&existing?.expiresAt>now)return false;
if(existing?.playerId===currentPlayerId()&&existing.expiresAt-now>30000)return true;
if(!force&&existing?.playerId===currentPlayerId()&&existing.expiresAt-now>30000)return true;
if(directBoardClaimRequests.has(boardId))return directBoardClaimRequests.get(boardId);
const request=(async()=>{
try{
@ -6389,13 +6408,17 @@ function mergeCloudPending(target,signal={}){
for(const id of signal.deleted||[]){target.metaIds.delete(id);target.stateIds.delete(id);target.deleted.add(id)}
target.globalChanged=target.globalChanged||signal.globalChanged===true;return target;
}
function takeCloudPendingBatch(source,limit=512){
const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1);let used=0;
function takeCloudPendingBatch(source,limit=16,preferredBoardId=null){
const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1);
batch.globalChanged=source.globalChanged===true;
for(const key of['metaIds','stateIds','deleted'])for(const id of source[key]||[])(used<maximum?batch:remainder)[key].add(id),used++;
const boardIds=[...new Set([...(source.stateIds||[]),...(source.metaIds||[])])],preferred=boardIds.includes(preferredBoardId)?preferredBoardId:boardIds[0],selectedBoardIds=new Set(preferred?[preferred]:[]);
for(const key of['metaIds','stateIds'])for(const id of source[key]||[])(selectedBoardIds.has(id)?batch:remainder)[key].add(id);
let deletedUsed=selectedBoardIds.size;
for(const id of source.deleted||[])(deletedUsed++<maximum?batch:remainder).deleted.add(id);
return{batch,remainder};
}
function cloudPendingHasWork(pending=cloudPushPending){return pending.globalChanged||pending.metaIds.size>0||pending.stateIds.size>0||pending.deleted.size>0}
function cloudMutationId(payload,changeSeq){const signature=JSON.stringify(payload);let value=2166136261;for(let index=0;index<signature.length;index++)value=Math.imul(value^signature.charCodeAt(index),16777619);const hash=(value>>>0).toString(16).padStart(8,'0'),owner=String(sessionId||'client').replace(/[^A-Za-z0-9_-]/g,'').slice(0,40)||'client';return`${owner}-${Math.max(0,Number(changeSeq)||0).toString(36)}-${hash}`}
function cloudProfileIdentity(profile=data.cloudProfile){return profile?`${profile.playerId}.${profile.token}`:''}
function scheduleCloudCheckpointRetry(){if(cloudCheckpointRetryTimer)return;cloudCheckpointRetryTimer=setTimeout(()=>{cloudCheckpointRetryTimer=0;if(hasPendingPersistence())void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()})},1000)}
function restoreCloudPushPending(){cloudPushPending=emptyCloudPending();mergeCloudPending(cloudPushPending,currentCloudPending())}
@ -6413,7 +6436,7 @@ async function fetchJson(url,options={},timeout=5000){
const base=bases[index],controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);
try{
const endpoint=cloudEndpointUrl(url,base),response=await fetch(endpoint,{...options,signal:controller.signal,headers:{'content-type':'application/json',...(options.headers||{})}}),body=await response.json().catch(()=>({}));
if(statusProbe&&response.ok&&body?.available===true){cloudApiBaseUrl=base;return body}
if(statusProbe&&response.ok&&body?.available===true){cloudApiBaseUrl=base;cloudApiPhpBridgeActive=response.headers.get('x-linkfield-bridge')==='php'||base===cloudApiBridgeUrl;return body}
if(!response.ok){const error=new Error(body.error||`HTTP ${response.status}`);error.status=response.status;error.body=body;throw error}
if(statusProbe&&index<bases.length-1){lastError=new Error('Cloud status endpoint did not return a shared-world response');continue}
return body;
@ -6438,6 +6461,7 @@ function addClearFeedEvent(event){
}
function applyCloudEnvelope(result,{initial=false}={}){
let changed=false;if(result?.player?.name&&data.playerName!==result.player.name){data.playerName=String(result.player.name).slice(0,24);changed=true}if(Array.isArray(result?.player?.purchases))applyPlayerEconomyEnvelope(result);
if(result?.clearEventsGap===true&&!initial){console.warn('LinkField: shared clear-event history gap; state was resynchronized from the authoritative snapshot');toast('\u5171\u6709\u5c65\u6b74\u306e\u4e00\u90e8\u3092\u518d\u540c\u671f\u3057\u307e\u3057\u305f\u3002',3000)}
const latest=Math.max(0,Number(result?.latestEventRevision)||0),events=Array.isArray(result?.clearEvents)?result.clearEvents:[];
for(const event of events)if(event?.id)removeClaim(event.id,'cleared');if(initial&&!(data.worldFeedRevision>0)){data.worldFeedRevision=latest;changed=true}else{for(const event of events)if((event.revision||0)>(data.worldFeedRevision||0))addClearFeedEvent(event);if(latest>(data.worldFeedRevision||0)){data.worldFeedRevision=latest;changed=true}}
if(changed)markGlobalDirty(false);return changed;
@ -6446,7 +6470,7 @@ async function createCloudProfile(){const result=await fetchJson('/api/cloud/ses
async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,authoritative=false}={}){
if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy)return false;if(!cloudAvailable||!data.cloudProfile||cloudSyncing&&!force)return false;
if(interactionActive()||[...rendered.values()].some(board=>board.drawing?.keyboardActive))return false;if(hasPendingPersistence()&&!await flushSave())return false;
const profileIdentity=cloudProfileIdentity();cloudSyncing=true;setCloudStatus('共有取得中','syncing');let pulled=false;
const profileIdentity=cloudProfileIdentity();cloudSyncing=true;setCloudStatus('共有取得中','syncing');let pulled=false,reopenedAfterPull=0;
try{
const previousRevision=sinceOverride??data.cloudRevision??0,remoteIds=new Set();let cursor=0,targetRevision=null,changed=false,fullSnapshot=false,firstPage=true,envelopeChanged=false,authoritativeWorld=authoritative===true;
do{
@ -6463,25 +6487,29 @@ async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,auth
cursor=Number.isSafeInteger(result.nextCursor)?result.nextCursor:0;
}while(cursor);
data.cloudRevision=targetRevision||0;lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();
if(changed){cloudApplyingRemote=true;try{if(fullSnapshot)for(const id of Object.keys(data.metas))if(!remoteIds.has(id)&&(authoritativeWorld||!cloudJournalMetaIds.has(id)&&!cloudJournalStateIds.has(id))){clearSharedWorldJournalRow('meta',id);clearSharedWorldJournalRow('state',id);delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id))}resolveMergedOverlaps();statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}refreshWorldView({rebuild:true,syncConnections:true,persist:false});if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world pull could not be committed locally')}}else if(data.cloudRevision!==previousRevision||envelopeChanged){markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world revision could not be committed locally')}}
if(changed){cloudApplyingRemote=true;try{if(fullSnapshot)for(const id of Object.keys(data.metas))if(!remoteIds.has(id)&&(authoritativeWorld||!cloudJournalMetaIds.has(id)&&!cloudJournalStateIds.has(id))){clearSharedWorldJournalRow('meta',id);clearSharedWorldJournalRow('state',id);delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id))}resolveMergedOverlaps();statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}reopenedAfterPull=reopenMissingGateExpansions();refreshWorldView({rebuild:true,syncConnections:true,persist:false});if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world pull could not be committed locally')}}else if(data.cloudRevision!==previousRevision||envelopeChanged){markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world revision could not be committed locally')}}
setCloudStatus();pulled=true;return true;
}catch(error){setCloudStatus();console.warn('BEND FIELD: shared-world pull failed',error);return false}
finally{cloudSyncing=false;setCloudStatus();restoreCloudPushPending();if(cloudPendingHasWork())armCloudPush(pulled?250:5000)}
finally{cloudSyncing=false;setCloudStatus();restoreCloudPushPending();if(reopenedAfterPull)scheduleExpansionRepair(150);if(cloudPendingHasWork())armCloudPush(pulled?250:5000)}
}
function scheduleCloudPush(signal={},delay=350){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)}
async function pushCloudPending(){
function scheduleCloudPush(signal={},delay=120){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)}
async function pushCloudPending(preferredBoardId=null){
clearTimeout(cloudPushTimer);cloudPushTimer=null;if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy){armCloudPush(1000);return false}if(!cloudAvailable||!data.cloudProfile||cloudSyncing||!cloudPendingHasWork())return false;
const{batch:pending,remainder}=takeCloudPendingBatch(cloudPushPending),profileIdentity=cloudProfileIdentity();cloudPushPending=remainder;
const{batch:pending,remainder}=takeCloudPendingBatch(cloudPushPending,16,preferredBoardId),profileIdentity=cloudProfileIdentity();cloudPushPending=remainder;
const globalSignature=sharedWorldGlobalSignature();if(!pending.metaIds.size&&!pending.stateIds.size&&!pending.deleted.size&&pending.globalChanged&&globalSignature===lastCloudWorldGlobalSignature){cloudJournalGlobalChanged=false;cloudOutboxDeleteKeys.add('global');data.cloudPending=currentCloudPending();restoreCloudPushPending();if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();return false}return true}
cloudSyncing=true;setCloudStatus('共有送信中','syncing');let payload=null,sentMetaRevs=new Map(),sentStateRevs=new Map(),sentCloudChangeSeq=cloudJournalChangeSeq,retryDelay=250;
try{
const rows=await cloudRowsForStorage(pending.metaIds,pending.stateIds);payload={baseRevision:data.cloudRevision||0,global:sharedWorldGlobalForCloud(),metas:rows.metas,states:rows.states,deleted:[]};sentMetaRevs=new Map(payload.metas.map(meta=>[meta.id,meta.rev||0]));sentStateRevs=new Map(payload.states.map(row=>[row.id,row.value?.rev||0]));
const rows=await cloudRowsForStorage(pending.metaIds,pending.stateIds);payload={baseRevision:data.cloudRevision||0,global:sharedWorldGlobalForCloud(),metas:rows.metas,states:rows.states,deleted:[]};payload.mutationId=cloudMutationId(payload,sentCloudChangeSeq);sentMetaRevs=new Map(payload.metas.map(meta=>[meta.id,meta.rev||0]));sentStateRevs=new Map(payload.states.map(row=>[row.id,row.value?.rev||0]));
const result=await fetchJson('/api/cloud/push',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify(payload)},30000);if(cloudProfileIdentity()!==profileIdentity){mergeCloudPending(cloudPushPending,pending);retryDelay=5000;setCloudStatus();return false}
serverClockOffset=result.serverTime-Date.now();data.cloudRevision=result.revision;applyCloudEnvelope(result);lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();acknowledgeCloudPending(pending,sentMetaRevs,sentStateRevs,sentCloudChangeSeq);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();setCloudStatus();return false}setCloudStatus();return true;
}catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus()}else if(error.status===409&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,payload.baseRevision,{authoritative:payload.baseRevision===0});retryDelay=pulled?250:5000}else if(error.status===400&&payload?.metas?.length){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus()}else{retryDelay=5000;setCloudStatus();console.warn('BEND FIELD: shared-world push failed',error)}return false}
}catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const boardId=error.body?.boardId||payload.states?.[0]?.id,claimed=boardId?await requestBoardClaim(boardId,{allowLocalSolved:true}):false;if(!claimed){const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000}else retryDelay=0;setCloudStatus()}else if(error.status===409&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,payload.baseRevision,{authoritative:payload.baseRevision===0});retryDelay=pulled?250:5000}else if(error.status===400&&payload?.metas?.length){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus()}else{retryDelay=5000;setCloudStatus();console.warn('BEND FIELD: shared-world push failed',error)}return false}
finally{cloudSyncing=false;setCloudStatus();if(cloudPendingHasWork())armCloudPush(retryDelay)}
}
function scheduleWorldPoll(delay=document.visibilityState==='visible'?1200:10000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(1000,delay))}
function scheduleWorldPoll(delay=document.visibilityState==='visible'?30000:120000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(5000,delay))}
function scheduleRemoteWorldPull(revision,delay=60){
remoteWorldRevision=Math.max(remoteWorldRevision,Math.max(0,Number(revision)||0));if(remoteWorldRevision<=(data.cloudRevision||0))return false;
clearTimeout(remoteWorldPullTimer);remoteWorldPullTimer=setTimeout(async()=>{remoteWorldPullTimer=0;if(remoteWorldRevision<=(data.cloudRevision||0))return;const pulled=await pullCloudWorld();if(!pulled&&remoteWorldRevision>(data.cloudRevision||0))scheduleRemoteWorldPull(remoteWorldRevision,500)},Math.max(0,delay));return true;
}
async function fetchCurrentSharedWorldStatus(){
let lastStatus=null,lastError=null;
for(let attempt=0;attempt<6;attempt++){

View file

@ -1,368 +0,0 @@
# Effects and Cosmetics Performance Plan
Status: implementation complete; release gates defined in the browser benchmark
Scope: client-side reaction effects, the Aurora shop-only line color, completion and gem effects, and cosmetic inventory/shop rendering
Primary constraint: improve performance without reducing visual quality
## 1. Goal
Make effects and cosmetics cheaper to prepare, render, update, and clean up while preserving their current appearance and behavior.
This plan covers:
- the `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet` reaction styles;
- the Aurora shop-only line color, including line, endpoint, connector, and gate presentation;
- puzzle-completion and gem-collection animations;
- cosmetic inventory and shop rendering, including the large cursor catalog;
- the measurement and browser-test coverage needed to prevent regressions.
It does not change gameplay, prices, ownership, realtime authority, or the artistic design of any effect.
## 2. Non-negotiable visual-fidelity contract
Performance work must not automatically degrade an effect. In particular, an optimization must not:
- replace a purchased effect with `classic`;
- drop, merge, or skip a visible local or remote effect;
- reduce particle, emoji, star, trail, ring, crack, or burst counts;
- shorten an effect, shrink its visible area, or remove a layer;
- reduce color depth, shadow, glow, compositing, or animation resolution;
- lower the existing 30 FPS visual scheduler cap;
- substitute a cheaper effect when the client is busy;
- add an automatic quality tier based on frame rate, device class, effect count, or battery state.
The existing user-selected lightweight/reduced-effects setting and the operating system's reduced-motion preference remain supported because they are explicit user or accessibility choices. They must not become an automatic overload response.
If an extreme overlap exceeds the performance budget, the client must render the complete visuals, record the overload, and recover cleanly. A missed performance target is preferable to silently changing what the player bought or what other players see.
## 3. Current baseline
The baseline below describes the implementation at the time this plan was written.
| Area | Current behavior | Main performance concern |
| --- | --- | --- |
| Frame scheduling | Global, auxiliary, reaction, and drag visual work is capped at 30 FPS. | Effect-specific cost is not separated from other frame work. |
| Reaction canvas | One viewport-sized, DPR 1 canvas is cleared and redrawn while reactions are active. Offscreen reactions are culled outside a 420 px margin. | Full-canvas clear, repeated state changes, and every active reaction's model work occur on the critical frame path. |
| Reaction models | Deterministic values are repeatedly derived from reaction strings during drawing. Geometry, trigonometry, gradients, shadows, and emoji text are produced during frames. | The renderer repeats immutable work and allocates short-lived canvas objects. |
| Aurora line color | A tracked controller advances through a curated palette every 2 seconds and writes one CSS custom property on the world container while Aurora presentation nodes are visible. | Keep line, endpoint, connector, and gate membership synchronized without broad DOM queries or document-wide style invalidation. |
| Gem collection | Ten to eighteen DOM particles are created and animated for a normal collection. | Node allocation, individual insertion, keyframe arrays, promises, and cleanup all scale with each collection. |
| Completion effect | Flash and burst elements are created per completion and removed later. | Repeated DOM allocation and timer cleanup can accumulate during rapid completions. |
| Cosmetic UI | The catalog contains hundreds of entries, mostly cursors. Inventory rendering replaces and rebuilds its children. | Large owned/debug inventories can cause DOM construction, image decoding, style calculation, and scroll instability. |
| Diagnostics | `BEND_PERF` already exposes timings, counters, gauges, long tasks, interaction frames, and input delay. | There are no dedicated reaction-style, Aurora, cosmetic-particle, or inventory-render metrics. |
| Browser benchmark | The current interaction benchmark injects one `classic` reaction. | Heavy styles, overlapping remote effects, lifecycle cleanup, and visual equivalence are not benchmarked. |
Before implementation starts, capture the baseline on the same browser build and hardware that will be used for acceptance. Baseline results belong in the benchmark output rather than as hand-copied numbers in this document.
## 4. Performance targets
These are acceptance targets for a 1280 × 900 Edge viewport after a warm-up run. Phase 0 must record the initial baseline, but the targets must not be loosened merely to make a change pass.
| Scenario | Target |
| --- | --- |
| One active heavy reaction | `drawReactionLayer` p95 at or below 6 ms; p99 at or below 8 ms. The 1 ms p95 exception preserves exact transformed emoji rendering after the faster atlas path failed visual comparison. |
| Four simultaneous heavy reactions | Reaction draw p95 at or below 12 ms; p99 at or below 20 ms |
| Normal active-effect cadence | Reaction frame-gap p95 at or below 45 ms, with no more than 30 reaction commits per second |
| Reaction scheduler | 30 visual commits per second, with a 37.5 callbacks/second short-window envelope for the immediate startup timer/RAF callback on 60, 120, or 144 Hz displays |
| Effect plus pan, zoom, or pickup drag | Existing interaction benchmark gates continue to pass; input-to-display p95 must not regress by more than 10% from the pre-change baseline |
| Reaction publication/spawn | Synchronous client setup at or below 2 ms p95 |
| Aurora tick | Callback p95 at or below 0.25 ms; no more than two color writes per second |
| Inactive/hidden Aurora | No query, timer callback, or color write while no Aurora path is visible or while the document is hidden |
| Normal gem collection | Setup p95 at or below 3 ms while retaining the current ten-to-eighteen-particle range |
| Effect cleanup | No effect-owned DOM nodes, animation handles, timers, or reaction cache entries remain after their cleanup deadline |
| Sequential stress | After 100 sequential effects and a settled garbage collection opportunity, retained heap is no more than 2 MB above the settled baseline, excluding the bounded shared asset cache |
| Full inventory/debug inventory open | Initial render at or below 100 ms, no task at or above 50 ms, and no unexpected scroll movement |
| Cosmetic equip/update | In-place update at or below 8 ms p95 without rebuilding the whole inventory |
The benchmark must report results even when a gate fails. It must never enable reduced effects to obtain a passing number.
## 5. Implementation workstreams
### Phase 0 — Measure the real cost first
Add named `BEND_PERF` measurements around the existing code paths:
- `reactionFrame`: total reaction-layer callback time;
- `reactionStyle.<style>`: drawing time for each reaction style;
- `reactionPrepare`: one-time immutable model preparation;
- `reactionComposite`: final canvas compositing work;
- `auroraTick`: Aurora activation check and color update;
- `gemEffectSetup` and `completionEffectSetup`;
- `inventoryRender` and `inventoryPatch`.
Add counters and gauges for:
- active and visible reaction counts;
- reaction frames, deadline-timer callbacks, draw-RAF callbacks, and skipped scheduler opportunities;
- emoji draws, gradient creations, path builds, and canvas pixels cleared;
- prepared-model, glyph-atlas, and static-layer cache hits, misses, size, and evictions;
- active cosmetic DOM particles and pooled nodes;
- Aurora active-path count and color writes;
- inventory nodes created, reused, removed, and images decoded.
Each reaction must keep its style name in the metric, but metrics must not contain player identifiers, emoji text, or other unbounded labels.
Deliverable: a repeatable baseline report for every effect and overlap scenario in Section 7.
### Phase 1 — Prepare immutable reaction data once
Move deterministic model construction out of the draw loop and into reaction normalization/application:
1. Compute and store the reaction seed once.
2. Precompute stable angles, radii, offsets, sizes, rotations, color choices, crack branches, star positions, lifetime-independent trail coefficients, and bloom membership.
3. Store normalized duration values and style-specific constants on a compact prepared model.
4. Keep time-dependent interpolation, viewport-dependent geometry/bounds, camera transformation, alpha, and compositing in the frame callback.
5. Delete the prepared model when its reaction expires or is explicitly removed.
Use typed arrays where they reduce object churn without making the model harder to validate. The prepared values must be generated from the same deterministic inputs so that the result is visually identical at every sampled lifetime.
Expected result: string hashing, most trigonometry, and immutable geometry allocations disappear from active frames.
### Phase 2 — Cache expensive drawing assets
Build bounded caches that change how pixels are produced, not which pixels are intended:
- Create an emoji glyph atlas keyed by emoji, rendered size, shadow/glow recipe, device scale, and browser font identity. Draw cached glyphs with `drawImage` instead of repeating `fillText`.
- Cache `Path2D` objects for static rings, cracks, star shapes, and burst geometry where coordinates do not change.
- Pre-render visually static effect layers to transparent offscreen surfaces. Keep dynamic translation, scale, rotation, alpha, and color stages on the reaction canvas.
- Reuse gradient recipes or pre-rendered gradient textures when their stops and local bounds are unchanged.
- Prewarm the currently equipped effect and its common glyphs during an idle callback after the initial world render.
All caches must:
- have a documented byte or entry bound;
- use least-recently-used eviction;
- expose hit, miss, size, and eviction metrics;
- use a synchronous exact-render fallback on a miss;
- evict only reusable data, never an active effect;
- clear browser-dependent assets when font/device-scale inputs change.
Do not use a low-resolution cache and scale it up. Offscreen surfaces must retain the current effective resolution and compositing behavior.
Expected result: repeated emoji shaping, gradient allocation, and static geometry painting are replaced by bounded image and path reuse.
### Phase 3 — Reduce canvas state and pixel work
After Phases 1 and 2 are measured, optimize the reaction layer:
1. Replace the current every-display-refresh RAF polling with a timer-to-RAF scheduler: wait until the next 30 FPS deadline is near, then use one animation frame for display-synchronized drawing.
2. Derive deadlines from an absolute timeline so timer drift cannot lower the sustained cadence.
3. Group compatible draws so font, shadow, blend mode, alpha, and transform state change less often.
4. Replace repeated `save()`/`restore()` pairs with explicit state restoration where benchmarks prove it safe.
5. Reuse paths and temporary arrays instead of allocating them per frame.
6. Track the previous and current visual bounds of every reaction.
7. Test clearing the union of dirty bounds instead of the full canvas.
8. If dirty rectangles cause trails, clipping, blend changes, or edge artifacts in any golden frame, retain the full clear and rely on the other workstreams.
9. Composite each prepared offscreen layer once per reaction per frame.
Worker preparation with `OffscreenCanvas` may be added only for immutable asset preparation. The main thread must retain a visually identical fallback for browsers that do not support the worker path. Do not put input delivery or camera state behind asynchronous worker messages.
Expected result: less state churn and fewer cleared/redrawn pixels, with a safe full-canvas fallback.
### Phase 4 — Make Aurora lifecycle-aware and locally scoped
Aurora is implemented as a shop-only line-color contract rather than a separate equip slot:
- Track mounted Aurora paths, connectors, endpoint nodes, and gate nodes as boards render or are removed; do not query the DOM on a color tick.
- Start one scheduler when the first visible Aurora presentation node appears.
- Stop it when the active count reaches zero.
- Suspend it while `document.hidden` is true and resume from a time-correct deadline.
- Advance through a curated palette exactly once every 2 seconds.
- Write `--aurora-rgb` on the shared world container, not on `document.body`.
- Avoid duplicate writes when the selected value is unchanged.
- Apply Aurora to newly drawn paths and to every usable, unsealed gate while it is equipped. Existing non-Aurora paths retain their recorded appearance.
- Repaint mounted boards once when the equipped line color changes so gate membership and saved path styling update immediately.
- Migrate the legacy `lineEffectStyle: "aurora"` saved value to the Aurora line-color item ID without discarding the equipped appearance.
Expected result: zero Aurora work when inactive, one curated color selection per two seconds when active, and consistent line/gate presentation.
### Phase 5 — Pool completion and gem DOM effects
Preserve the current particle counts, paths, durations, colors, easing, and layering while removing repeated setup work:
- Maintain a pool large enough for the current maximum normal gem burst and any documented concurrent bursts.
- Reset and reuse particle nodes instead of creating and discarding each node.
- Append newly required nodes with one `DocumentFragment`.
- Reuse immutable keyframe and animation-option templates; fill only the values that differ for a particle.
- Use one owner/controller to track animations and cleanup rather than one unobserved promise chain per particle.
- Pool the completion flash and burst nodes and cancel stale timers before reuse.
- On world reset, navigation, or teardown, cancel animations and return every owned node to the pool.
The pool must be bounded. If concurrency exceeds its size, create the additional nodes required to preserve visuals, then release the overflow nodes after the burst.
Expected result: the same animation with lower node, object, promise, and timer churn.
### Phase 6 — Patch cosmetic UI instead of rebuilding it
Keep the current catalog, ordering, card design, category behavior, and item visibility while reducing UI work:
1. Give every cosmetic card a stable key based on catalog ID.
2. Reuse existing category and card nodes across inventory renders.
3. Patch only changed state such as ownership, equipped status, price, and selected styling.
4. Preserve the scrolling element and its exact `scrollTop` during every patch.
5. Apply `content-visibility: auto` and an accurate intrinsic-size estimate to offscreen categories/cards.
6. Lazy-decode flag and thumbnail images near the viewport; cache successfully decoded assets.
7. Batch class and text changes before the browser's style/layout phase.
8. Keep keyboard order, focus, screen-reader names, and category collapse behavior unchanged.
If the full debug/owned inventory still misses its budget, add accessible windowing as a later step. Windowing must preserve the scrollbar range, focus restoration, category navigation, and exact item visuals; it must not remove discoverable items or unexpectedly move the list.
Expected result: opening a large catalog and equipping an item no longer creates a full-tree rebuild or automatic scroll jump.
### Phase 7 — Harden lifecycle and overlap behavior
Ensure optimization state cannot leak or change multiplayer behavior:
- cancel reaction animation frames when no reactions are active;
- remove expired reaction models and cached active surfaces deterministically;
- suspend background visual schedulers while the page is hidden, then resume from authoritative time rather than replaying queued frames;
- clear effect-owned state during world reset and client teardown;
- render all valid overlapping reactions from different players;
- retain the existing server and client rule for each player's concurrent special reaction;
- record an overload gauge when valid overlap exceeds the tested matrix, without dropping or simplifying effects.
No client cache may become a source of gameplay or ownership truth.
## 6. Priority and delivery order
| Priority | Change | Reason | Dependency |
| --- | --- | --- | --- |
| P0 | Effect-specific instrumentation and benchmark matrix | Makes all later gains and regressions visible | None |
| P0 | One-time reaction model preparation | Removes repeated CPU/allocation work with low visual risk | Metrics |
| P0 | Emoji/static-layer/path caches | Targets the most expensive repeated canvas work | Prepared models |
| P0 | Deadline-based 30 FPS scheduler | Avoids polling at 60144 Hz without changing visible cadence | Scheduler metrics |
| P1 | Aurora lifecycle and scoped variable | Small, isolated change with clear inactive-state benefit | Metrics |
| P1 | Gem/completion node pooling | Removes predictable DOM churn | Metrics |
| P1 | Keyed cosmetic inventory patching | Addresses large catalogs and scroll movement | UI metrics |
| P1 | Canvas state batching | Reduces frame cost after model and asset work are separated | Prepared models and caches |
| P2 | Dirty-rectangle clearing | Can reduce pixel work but has higher artifact risk | Golden-frame coverage |
| P2 | Worker/offscreen preparation | Useful only if main-thread preparation still misses the budget | Stable prepared-model format |
| P2 | Accessible inventory windowing | Use only if keyed patching and content visibility are insufficient | UI benchmark and accessibility tests |
Each row should ship independently where practical. Capture a before/after trace and memory result for every row rather than combining all optimizations into one unreviewable change.
## 7. Verification matrix
### Effect scenarios
Run all of these with reduced effects disabled:
- each reaction style alone: `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet`;
- four simultaneous heavy reactions from different players;
- eight simultaneous mixed reactions from different players as an overload/recovery test;
- Aurora alone and Aurora while reactions are active;
- a completion burst;
- minimum and maximum normal gem bursts;
- rapid sequential gem and completion effects;
- full owned inventory and debug/all-item inventory;
- cosmetic equip changes while the inventory is scrolled.
### Interaction combinations
For each relevant effect scenario, measure:
- idle camera;
- continuous pan;
- continuous zoom;
- pickup drag;
- pickup edge-pan;
- shop/inventory scrolling.
This ensures effect work does not reintroduce the previously observed camera and pickup-display stalls.
### Environment matrix
At minimum:
- Edge at 1280 × 900 and device scale 1;
- Edge mobile-size viewport at 390 × 844;
- normal CPU and browser 4× CPU throttling;
- visible document, hidden for the middle of an effect, and resume;
- cold cache and warm cache;
- normal catalog and debug/all-item catalog.
Use one controlled Edge instance at a time and close its temporary profile after the matrix. The test runner must not leave background browser processes or temporary profiles behind.
### Visual-equivalence checks
Use a test hook to inject exact normalized lifetime values of 0.10, 0.25, 0.50, 0.75, and 0.95, then capture deterministic reference frames before changing a renderer. Repeat the exact injected-lifetime captures after every visual-path optimization.
Compare:
- particle/glyph count and identity;
- bounds, position, rotation, and scale;
- ring, trail, crack, star, flash, and burst presence;
- color stops, shadow/glow extent, blend order, and alpha;
- start time, total duration, and fade timing;
- layering relative to the world and other reactions.
Pixel differences are acceptable only for demonstrated browser anti-aliasing noise. Use a small per-channel tolerance and require at least 99.5% of pixels within that tolerance. Any structural difference fails even if the aggregate pixel threshold passes.
### Functional and cleanup checks
Verify that:
- purchased/equipped styles still resolve to the same renderer;
- local and remote players see the same style and duration;
- overlapping valid reactions are all rendered;
- ownership, store pricing, and equip persistence are unchanged;
- hidden/resumed effects use authoritative elapsed time;
- no unexpected inventory scroll or focus movement occurs;
- all timers, animation frames, animations, pooled overflow nodes, and active models are cleaned up;
- the full test suite and the existing real-browser performance benchmark pass.
## 8. Planned code and test changes
| File or area | Planned responsibility |
| --- | --- |
| `app.js` | Prepared reaction models, bounded caches, effect metrics, reaction lifecycle, Aurora controller, pooled DOM effects, and keyed inventory patching |
| `style.css` | Narrow Aurora variable scope, content visibility/intrinsic sizing, and any pool reset styles that preserve current appearance |
| `test/browser-performance-benchmark.js` | Per-style, overlap, interaction-combination, lifecycle, cadence, and memory probes |
| `test/effects-performance-smoke-test.js` | Source/runtime invariants for cache bounds, cleanup, scheduler caps, overlap behavior, and the no-auto-degradation contract |
| Visual reference fixtures | Deterministic effect checkpoints and comparison metadata for supported Edge rendering |
| `docs/internal-system.md` | Final architecture and lifecycle after implementation |
| `docs/test-policy.md` | New effect-performance and visual-equivalence release gates |
`realtime-server.js` should not require a behavior change for this work. Server-side changes are only justified if additional diagnostics or deterministic test fixtures are needed; reaction validation and authority must remain intact.
## 9. Risks and safeguards
| Risk | Safeguard |
| --- | --- |
| Cached emoji differ from direct browser text rendering | Render the atlas with the same browser, font string, shadow recipe, scale, and compositing mode; compare golden frames before enabling it |
| A cache saves CPU but retains too much memory | Enforce a measured bound, expose byte/entry gauges, test eviction, and clear browser-dependent entries on environment changes |
| Dirty rectangles leave trails or clip glow | Include previous and current expanded bounds; immediately retain full clear if any golden or overlap case shows artifacts |
| Offscreen/worker output changes blending | Composite with the same alpha and blend order; keep the direct main-thread path as the correctness reference |
| Pool reuse leaks stale classes/styles | Centralize a complete reset routine and assert the reset state in tests |
| UI reuse introduces stale ownership/equip state | Patch from one normalized view model and test every ownership/equip transition |
| Visibility suspension changes lifetime | Derive life from authoritative timestamps on resume; never replay missed animation frames |
| An optimization accidentally becomes adaptive quality | Test source and runtime invariants that prohibit style substitution, count reduction, duration reduction, and automatic reduced-effects activation |
## 10. Definition of done
The work is complete only when:
- all current effects and cosmetics are visually unchanged under the checks in Section 7;
- no automatic quality degradation path exists;
- the targets in Section 4 pass in the supported Edge matrix;
- effect work does not regress pan, zoom, cursor, or pickup-drag responsiveness;
- caches, pools, schedulers, and prepared models are bounded and cleaned up;
- the inventory retains its exact scroll position and focus during updates;
- ownership, pricing, persistence, and realtime behavior are unchanged;
- benchmark reports include per-style timings, overlap results, cache statistics, and memory cleanup;
- documentation and release tests reflect the implemented architecture.
Implementation note: exact-output validation rejected transformed emoji atlases, cached laser layers, and dirty-rectangle clearing, so those paths retain direct rendering and full-canvas clearing. The accepted implementation uses immutable reaction models, absolute-deadline scheduling, batched direct text state, a bounded transform-neutral glyph cache, a bounded fixed-geometry `Path2D` cache for Orbit and Firework, scoped Aurora updates, bounded DOM pools with shared Gem animation templates, and signature-based localized inventory patches. Worker preparation and inventory windowing remain conditional only and are not enabled because the synchronous path and keyed catalog remain the authoritative visual/accessibility implementation. The browser gate loads deterministic checkpoints from `test/fixtures/effect-visual-checkpoints.json`, covers setup cost, active/inactive lifecycle, effect-plus-interaction cases, focus/scroll retention, and 100-cycle cleanup, and writes a machine-readable report. Normal-speed timing gates remain authoritative; the 4x CPU matrix is an overload/completeness diagnostic because this plan explicitly prefers full visuals to automatic degradation.
## 11. Explicit non-goals
This plan does not:
- redesign, retire, or simplify an effect;
- reduce the scheduler below 30 FPS;
- introduce automatic adaptive quality;
- change the number or duration of visible elements;
- change store selection, price, ownership, or persistence rules;
- change reaction rate limits or multiplayer authority;
- use lower-quality visuals as the definition of a performance fix.

View file

@ -160,6 +160,6 @@
<script src="client/input/gesture-coordinator.js"></script>
<script src="client/ui/cursor.js"></script>
<script src="field-persistence.js"></script>
<script src="app.js"></script>
<script src="app.js?v=48.0.3"></script>
</body>
</html>

View file

@ -13,6 +13,8 @@
"test:fast": "node test/run-all.js",
"test:browser": "node test/browser-performance-benchmark.js && node test/store-ui-browser-test.js",
"test:storage": "node test/browser-field-storage-benchmark.js",
"test:bridge": "node test/php-bridge-integration-test.js",
"smoke:public": "node scripts/public-smoke-test.js",
"test:policy": "node scripts/check-source-policy.js",
"test:ci": "npm run test:policy && npm run test:fast && npm run test:browser",
"benchmark:browser": "node test/browser-performance-benchmark.js",
@ -21,6 +23,10 @@
"stop": "node scripts/service-control.js stop",
"status": "node scripts/service-control.js status",
"restart": "node scripts/service-control.js restart",
"deploy": "node scripts/service-control.js deploy"
"deploy": "node scripts/service-control.js deploy",
"backup": "node scripts/service-control.js backup",
"backups": "node scripts/service-control.js backups",
"restore": "node scripts/service-control.js restore",
"recover": "node scripts/service-control.js recover"
}
}

View file

@ -5,6 +5,8 @@ const { URL } = require('url');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const MAX_MESSAGE_BYTES = 64 * 1024;
const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024;
const MAX_POLL_EVENTS = 256;
const MAX_VIEW_SPAN = 512;
const MAX_WORLD_COORD = 1_000_000_000;
const REACTION_EMOJIS = new Set(['👍','🤩','🙏','🧠','🎉']);
@ -64,11 +66,14 @@ function sendJson(client, value) {
if (client.transport === 'poll') {
const sequence = ++client.eventSequence;
client.events.push({sequence, message:value});
if (client.events.length > 256) client.events.splice(0, client.events.length - 256);
if (client.events.length > MAX_POLL_EVENTS) {const removed=client.events.splice(0, client.events.length - MAX_POLL_EVENTS);client.droppedThroughSequence=Math.max(client.droppedThroughSequence||0,...removed.map(event=>event.sequence))}
client.wakePoll?.();
return true;
}
if (!client.socket?.writable) return false;
try { client.socket.write(encodeFrame(1, Buffer.from(JSON.stringify(value)))); return true; }
if(client.backpressured)return false;
if((client.socket.writableLength||0)>MAX_SOCKET_BUFFER_BYTES){client.closed=true;client.socket.destroy();return false}
try {const accepted=client.socket.write(encodeFrame(1, Buffer.from(JSON.stringify(value))));if(!accepted){client.backpressured=true;client.socket.once('drain',()=>{client.backpressured=false})}return accepted;}
catch (_) { return false; }
}
function sendClose(client, code = 1000, reason = '') {
@ -80,7 +85,7 @@ function sendClose(client, code = 1000, reason = '') {
try { client.socket.end(encodeFrame(8, payload)); } catch (_) { client.socket.destroy(); }
}
function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/realtime', claimTtlMs = 5 * 60 * 1000, now = Date.now}) {
function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/realtime', claimTtlMs = 5 * 60 * 1000, now = Date.now, maxClients = 500, maxClientsPerPlayer = 5}) {
if (!server || typeof authenticate !== 'function' || typeof getBoardInfo !== 'function') throw new Error('Invalid realtime hub options');
const clients = new Map();
const claims = new Map();
@ -89,6 +94,7 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
let serial = Promise.resolve();
let nextConnection = 1;
let nextReaction = 1;
function playerClientCount(playerId){let count=0;for(const client of clients.values())if(!client.closed&&client.playerId===playerId)count++;return count}
function withSerial(task) {
const run = serial.then(task, task); serial = run.then(() => undefined, () => undefined); return run;
@ -205,12 +211,14 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
const event={type:'reaction',reaction:publicReaction(reaction),serverTime:timestamp};for(const other of subscribersAt(x,y))if(other.authenticated&&pointInViewport(other.viewport,x,y))sendJson(other,event);return true;
}
async function handleMessage(client, raw) {
const timestamp=now();if(!client.messageWindowStartedAt||timestamp-client.messageWindowStartedAt>=10_000){client.messageWindowStartedAt=timestamp;client.messageCount=0}client.messageCount=(client.messageCount||0)+1;if(client.messageCount>300){sendClose(client,1013,'Message rate exceeded');removeClient(client);return false}
let message; try { message = JSON.parse(raw); } catch (_) { return sendJson(client, {type:'error', error:'invalid-json'}); }
if (!message || typeof message !== 'object' || Array.isArray(message)) return;
if (!client.authenticated) {
if (message.type !== 'hello') return sendClose(client, 1008, 'Authentication required');
try {
const identity = await authenticate({playerId:message.playerId, token:message.token});
if(playerClientCount(identity.playerId)>=maxClientsPerPlayer)return sendClose(client,1013,'Too many player connections');
client.authenticated = true; client.playerId = identity.playerId; client.name = identity.name;
clearTimeout(client.authTimer); client.authTimer = 0;
sendJson(client, {type:'ready', presenceId:client.id, playerId:client.playerId, name:client.name, claimTtlMs, serverTime:now()});
@ -267,7 +275,7 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
}
}
function removeClient(client) {
if (!client || client.removed) return; client.removed = true; client.closed = true; clearTimeout(client.authTimer); unregisterViewport(client); clients.delete(client.id);
if (!client || client.removed) return; client.removed = true; client.closed = true; clearTimeout(client.authTimer);if(client.pollWaiter){clearTimeout(client.pollWaiter.timer);client.pollWaiter.resolve(null);client.pollWaiter=null} unregisterViewport(client); clients.delete(client.id);
for (const [boardId, claim] of claims) if (claim.ownerPresenceId === client.id) releaseBoardClaim(boardId, 'disconnected');
if (client.authenticated && Number.isFinite(client.x) && Number.isFinite(client.y)) broadcastAll({type:'player-left', presenceId:client.id, playerId:client.playerId, serverTime:now()}, other => pointInViewport(other.viewport, client.x, client.y));
}
@ -280,13 +288,15 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
function pollingEnvelope(client, afterSequence = 0) {
const after = Math.max(0, Math.floor(finite(afterSequence, 0)));
const events = client.events.filter(event => event.sequence > after);
const sequence = events.length ? events[events.length - 1].sequence : Math.max(after, client.eventSequence || 0);
const eventGap=after>client.eventSequence||after<(client.droppedThroughSequence||0);
const sequence = events.length ? events[events.length - 1].sequence : client.eventSequence || 0;
if (events.length) client.events = client.events.filter(event => event.sequence > sequence);
return {presenceId:client.id, sequence, messages:events.map(event => event.message), serverTime:now()};
return {presenceId:client.id, sequence, eventGap, messages:events.map(event => event.message), serverTime:now()};
}
function createPollingClient(identity) {
if(clients.size>=maxClients||playerClientCount(identity.playerId)>=maxClientsPerPlayer)return null;
const timestamp = now();
const client = {id:`h${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'poll', socket:null, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:true, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0, vy:0, cursorStyle:'default', cursorAt:0, lastCursorMessageAt:0, lastPongAt:timestamp, lastSeenAt:timestamp, fragments:[], fragmentBytes:0, fragmentOpcode:0, currentClaimBoardId:null, lastReactionAt:0, authTimer:0, viewportBucketKeys:[], playerId:identity.playerId, name:identity.name, events:[], eventSequence:0};
const client = {id:`h${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'poll', socket:null, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:true, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0, vy:0,cursorStyle:'default',cursorAt:0,lastCursorMessageAt:0,lastPongAt:timestamp,lastSeenAt:timestamp,fragments:[],fragmentBytes:0,fragmentOpcode:0,currentClaimBoardId:null,lastReactionAt:0,authTimer:0,viewportBucketKeys:[],playerId:identity.playerId,name:identity.name,events:[],eventSequence:0,droppedThroughSequence:0,pollWaiter:null,wakePoll:null,messageWindowStartedAt:timestamp,messageCount:0};
clients.set(client.id, client);
sendJson(client, {type:'ready', presenceId:client.id, playerId:client.playerId, name:client.name, claimTtlMs, serverTime:timestamp});
return pollingEnvelope(client, 0);
@ -295,11 +305,15 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
const client = pollingClientFor(identity, presenceId);
if (!client) return null;
await handleMessage(client, JSON.stringify(message || {}));
return pollingEnvelope(client, afterSequence);
return client.closed||client.removed?null:pollingEnvelope(client, afterSequence);
}
function pollPollingClient(identity, presenceId, afterSequence = 0) {
function pollPollingClient(identity, presenceId, afterSequence = 0, waitMs = 20_000) {
const client = pollingClientFor(identity, presenceId);
return client ? pollingEnvelope(client, afterSequence) : null;
if(!client)return null;const after=Math.max(0,Math.floor(finite(afterSequence,0)));
if(client.events.some(event=>event.sequence>after)||after>client.eventSequence||after<(client.droppedThroughSequence||0))return pollingEnvelope(client,after);
if(client.pollWaiter){clearTimeout(client.pollWaiter.timer);client.pollWaiter.resolve(pollingEnvelope(client,client.pollWaiter.after));client.pollWaiter=null}
if(waitMs<=0)return pollingEnvelope(client,after);
return new Promise(resolve=>{const finish=()=>{if(client.pollWaiter?.resolve!==resolve)return;clearTimeout(client.pollWaiter.timer);client.pollWaiter=null;client.wakePoll=null;resolve(client.closed?null:pollingEnvelope(client,after))},timer=setTimeout(finish,Math.max(1000,Math.min(25_000,waitMs)));client.pollWaiter={resolve,after,timer};client.wakePoll=finish});
}
function disconnectPollingClient(identity, presenceId) {
const client = pollingClientFor(identity, presenceId);
@ -310,12 +324,13 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
function handleUpgrade(req, socket, head) {
let url; try { url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); } catch (_) { socket.destroy(); return; }
if (url.pathname !== path && !url.pathname.endsWith(path)) { socket.destroy(); return; }
if(clients.size>=maxClients){socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');socket.destroy();return}
const key = req.headers['sec-websocket-key'], version = req.headers['sec-websocket-version'];
if (req.method !== 'GET' || typeof key !== 'string' || version !== '13') { socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); socket.destroy(); return; }
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
socket.setNoDelay(true);
const client = {id:`p${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'websocket', socket, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:false, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0, vy:0, cursorStyle:'default', cursorAt:0, lastCursorMessageAt:0, lastPongAt:now(), lastSeenAt:now(), fragments:[], fragmentBytes:0, fragmentOpcode:0, currentClaimBoardId:null, lastReactionAt:0, authTimer:0, viewportBucketKeys:[], events:[], eventSequence:0};
const timestamp=now(),client = {id:`p${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'websocket', socket, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:false, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0,vy:0,cursorStyle:'default',cursorAt:0,lastCursorMessageAt:0,lastPongAt:timestamp,lastSeenAt:timestamp,fragments:[],fragmentBytes:0,fragmentOpcode:0,currentClaimBoardId:null,lastReactionAt:0,authTimer:0,viewportBucketKeys:[],events:[],eventSequence:0,backpressured:false,messageWindowStartedAt:timestamp,messageCount:0};
clients.set(client.id, client);
client.authTimer = setTimeout(() => sendClose(client, 1008, 'Authentication timeout'), 5000);
socket.on('data', chunk => consumeFrames(client, chunk)); socket.on('error', () => removeClient(client)); socket.on('close', () => removeClient(client)); socket.on('end', () => removeClient(client));
@ -340,6 +355,7 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
hasClaim(playerId, boardId) { pruneClaims(); const claim = claims.get(boardId); return claimActive(claim) && claim.ownerPlayerId === playerId; },
releaseBoardClaim,
broadcastClearEvents(events) { for (const event of events || []) broadcastAll({type:'board-cleared', event, serverTime:now()}); },
broadcastWorldRevision(revision, boardIds = [], page = null) { const cleanRevision=Math.max(0,Math.floor(finite(revision,0))),ids=[...new Set([...boardIds].map(cleanBoardId).filter(Boolean))],message={type:'world-revision',revision:cleanRevision,boardIds:ids,serverTime:now()};if(page&&typeof page==='object')message.page=page;broadcastAll(message); },
notifyProfileChange(playerId, name) { for (const client of clients.values()) if (client.playerId === playerId) client.name = name; broadcastAll({type:'player-profile', playerId, name, serverTime:now()}); },
createPollingClient,
handlePollingMessage,

View file

@ -6,5 +6,5 @@
appBaseUrl=new URL('./',scriptUrl).href;
apiBridgeUrl=new URL('api-bridge.php',appBaseUrl).href;
}catch(_){appBaseUrl='';apiBridgeUrl=''}
root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,apiBridgeUrl,realtimeTransport:'http-poll'});
root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,apiBridgeUrl,realtimeTransport:'auto'});
})(globalThis);

View file

@ -0,0 +1,89 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ApacheConfig,
[Parameter(Mandatory = $true)]
[string]$PhpIni,
[switch]$Apply,
[switch]$SkipApacheSyntaxCheck
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-ConfigFile([string]$Value, [string]$Label) {
$resolved = (Resolve-Path -LiteralPath $Value -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) {
throw "$Label is not a file: $resolved"
}
return $resolved
}
function Replace-ManagedBlock([string]$Text, [string]$Begin, [string]$End, [string]$Block) {
$normalized = $Text -replace "`r`n?", "`n"
$pattern = '(?ms)(?:^|\n)' + [regex]::Escape($Begin) + '.*?' + [regex]::Escape($End) + '(?:\n|$)'
$without = [regex]::Replace($normalized, $pattern, "`n").TrimEnd("`n")
if ($without.Length -eq 0) { return "$Block`n" }
return "$without`n`n$Block`n"
}
function Write-Utf8NoBom([string]$File, [string]$Text) {
[System.IO.File]::WriteAllText($File, $Text, [System.Text.UTF8Encoding]::new($false))
}
$apacheFile = Resolve-ConfigFile $ApacheConfig 'Apache configuration'
$phpFile = Resolve-ConfigFile $PhpIni 'PHP configuration'
$apacheBegin = '# BEGIN LINKFIELD HOST HARDENING'
$apacheEnd = '# END LINKFIELD HOST HARDENING'
$phpBegin = '; BEGIN LINKFIELD HOST HARDENING'
$phpEnd = '; END LINKFIELD HOST HARDENING'
$apacheBlock = "$apacheBegin`nServerTokens Prod`nServerSignature Off`n$apacheEnd"
$phpBlock = "$phpBegin`nexpose_php = Off`n$phpEnd"
$apacheOriginal = [System.IO.File]::ReadAllText($apacheFile)
$phpOriginal = [System.IO.File]::ReadAllText($phpFile)
$apacheUpdated = Replace-ManagedBlock $apacheOriginal $apacheBegin $apacheEnd $apacheBlock
$phpUpdated = Replace-ManagedBlock $phpOriginal $phpBegin $phpEnd $phpBlock
$apacheAlreadySafe = $apacheUpdated -eq ($apacheOriginal -replace "`r`n?", "`n")
$phpAlreadySafe = $phpUpdated -eq ($phpOriginal -replace "`r`n?", "`n")
Write-Output "Apache config: $apacheFile"
Write-Output "PHP config: $phpFile"
Write-Output "Apache ServerTokens/ServerSignature: $(if ($apacheAlreadySafe) {'already managed'} else {'change required'})"
Write-Output "PHP expose_php: $(if ($phpAlreadySafe) {'already managed'} else {'change required'})"
if (-not $Apply) {
Write-Output 'Dry run only. Re-run with -Apply to create backups and write the managed settings.'
exit 0
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$apacheBackup = "$apacheFile.linkfield-backup-$stamp"
$phpBackup = "$phpFile.linkfield-backup-$stamp"
Copy-Item -LiteralPath $apacheFile -Destination $apacheBackup -Force
Copy-Item -LiteralPath $phpFile -Destination $phpBackup -Force
try {
Write-Utf8NoBom $apacheFile $apacheUpdated
Write-Utf8NoBom $phpFile $phpUpdated
if (-not $SkipApacheSyntaxCheck) {
$apacheRoot = Split-Path -Parent (Split-Path -Parent $apacheFile)
$httpd = Join-Path $apacheRoot 'bin\httpd.exe'
if (-not (Test-Path -LiteralPath $httpd -PathType Leaf)) {
throw "Apache syntax checker was not found at $httpd. Re-run with -SkipApacheSyntaxCheck only after locating another way to run httpd -t."
}
& $httpd -t -f $apacheFile
if ($LASTEXITCODE -ne 0) { throw "Apache rejected the updated configuration (exit $LASTEXITCODE)." }
}
} catch {
Copy-Item -LiteralPath $apacheBackup -Destination $apacheFile -Force
Copy-Item -LiteralPath $phpBackup -Destination $phpFile -Force
throw "Host hardening failed; both original files were restored. $($_.Exception.Message)"
}
Write-Output "Applied LinkField host hardening. Backups:"
Write-Output " $apacheBackup"
Write-Output " $phpBackup"
Write-Output 'Restart Apache, then run: npm run smoke:public -- https://2012r2.nishi.boats/~333/link-field/'

View file

@ -0,0 +1,7 @@
'use strict';
const BuildMeta=require('../build-meta');
const {checkPublicDeployment}=require('../server/public-health');
const baseValue=String(process.argv[2]||process.env.LINK_FIELD_PUBLIC_URL||'').trim();
if(!baseValue)throw new Error('Set LINK_FIELD_PUBLIC_URL or pass the public LinkField base URL.');
(async()=>{const result=await checkPublicDeployment(baseValue,{appVersion:BuildMeta.APP_VERSION,timeoutMs:15_000});console.log(`Public LinkField v${BuildMeta.APP_VERSION} is healthy at ${result.base}`)})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -4,15 +4,27 @@ const fs = require('fs');
const fsp = fs.promises;
const os = require('os');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const {spawn, execFile} = require('child_process');
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
const {checkPublicDeployment}=require('../server/public-health');
const {renderApacheBootstrap,replaceManagedBlock}=require('../server/apache-bridge');
const ROOT = path.resolve(__dirname, '..');
const SERVER_FILE = path.join(ROOT, 'server.js');
const RUNNER_FILE = path.join(ROOT, 'scripts', 'service-runner.js');
const SERVICE_ROOT = path.resolve(process.env.LINK_FIELD_SERVICE_DIR || path.join(os.homedir(), '.local', 'share', 'LinkField', 'service'));
const PID_FILE = path.join(SERVICE_ROOT, 'server.pid');
const IDENTITY_FILE = path.join(SERVICE_ROOT, 'service.identity.json');
const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
const TEST_DATA_ROOT=String(process.env.LINK_FIELD_TEST_DATA_ROOT||'').trim();
const WORLD_DIR=TEST_DATA_ROOT?path.join(path.resolve(TEST_DATA_ROOT),'world'):path.resolve(String(process.env.LINK_FIELD_WORLD_DIR||'/link-field/world'));
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(WORLD_DIR),'backups')));
const STARTUP_TIMEOUT_MS = 12_000;
const POLL_INTERVAL_MS = 100;
const MAX_LOG_BYTES = 10 * 1024 * 1024;
const LOG_GENERATIONS = 5;
const PUBLIC_ENTRIES = Object.freeze([
'index.html',
'style.css',
@ -44,33 +56,84 @@ function execFileText(command, args) {
return new Promise((resolve, reject) => execFile(command, args, {encoding:'utf8'}, (error, stdout) => error ? reject(error) : resolve(stdout)));
}
async function legacyLinkFieldPids() {
if (process.platform === 'win32') return [];
function rootMatches(value){
if(typeof value!=='string'||!value.trim())return false;
const actual=path.resolve(value),expected=ROOT;
return process.platform==='win32'?actual.toLowerCase()===expected.toLowerCase():actual===expected;
}
async function readWorldInstanceLock(worldDir=WORLD_DIR){
try{
const raw=(await fsp.readFile(path.join(worldDir,'server.pid'),'utf8')).trim();
if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true};
const value=JSON.parse(raw);return value&&typeof value==='object'?value:null;
}catch(_){return null}
}
function isFreshRuntimeRecord(record,maxAgeMs){
return Boolean(record&&rootMatches(record.root)&&Number.isSafeInteger(Number(record.pid))&&Number(record.pid)>0&&isProcessRunning(Number(record.pid))&&Date.now()-Number(record.heartbeatAt||0)<maxAgeMs);
}
async function isLinkFieldRoot(value){
if(rootMatches(value))return true;
if(typeof value!=='string'||!value.trim())return false;
try{const pkg=JSON.parse(await fsp.readFile(path.join(path.resolve(value),'package.json'),'utf8'));return['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)}catch(_){return false}
}
async function isTrustedRuntimeRecord(record,maxAgeMs){
const pid=Number(record?.pid),age=Date.now()-Number(record?.heartbeatAt||0);
return Boolean(Number.isSafeInteger(pid)&&pid>0&&pid!==process.pid&&isProcessRunning(pid)&&age>=-5000&&age<maxAgeMs&&await isLinkFieldRoot(record?.root));
}
async function processExecutableIsNode(pid){
if(!Number.isSafeInteger(pid)||pid<=0||pid===process.pid||!isProcessRunning(pid))return false;
if(process.platform==='win32'){
try{const output=await execFileText('powershell.exe',['-NoProfile','-NonInteractive','-Command',`(Get-Process -Id ${pid} -ErrorAction Stop).Path`]);return['node.exe','nodejs.exe'].includes(path.basename(output.trim()).toLowerCase())}catch(_){return false}
}
try{return['node','nodejs'].includes(path.basename(await fsp.readlink(`/proc/${pid}/exe`)).toLowerCase())}catch(_){return false}
}
async function legacyLinkFieldPids({worldDir=WORLD_DIR}={}) {
const matches=[];
const [identity,lock]=await Promise.all([readIdentity(),readWorldInstanceLock(worldDir)]);
if(await isTrustedRuntimeRecord(identity,5_000))matches.push(Number(identity.pid));
if(await isTrustedRuntimeRecord(lock,15_000))matches.push(Number(lock.pid));
if(process.platform==='win32'){
return[...new Set(matches.filter(pid=>pid!==process.pid))];
}
let output;
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
catch (_) { return []; }
const matches=[];
catch (_) { return [...new Set(matches.filter(pid=>pid!==process.pid))]; }
for (const line of output.split(/\r?\n/)) {
const match=line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);if(!match)continue;
const pid=Number(match[1]),executable=path.basename(match[2]).toLowerCase(),command=match[3];
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])server\.js(?:\s|$)/.test(command))continue;
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])(?:server\.js|service-runner\.js)(?:\s|$)/.test(command))continue;
let cwd='';try{cwd=await fsp.readlink(`/proc/${pid}/cwd`)}catch(_){}
const candidates=[cwd];const absolute=command.match(/(?:^|\s)(\/[^\s]*\/server\.js)(?:\s|$)/);if(absolute)candidates.push(path.dirname(absolute[1]));
let linkField=false;
for(const directory of candidates.filter(Boolean)){
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(pkg?.name==='link-field-v47-shared-world'){linkField=true;break}}catch(_){}
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)){linkField=true;break}}catch(_){}
}
if(linkField)matches.push(pid);
}
return [...new Set(matches)];
return [...new Set(matches.filter(pid=>pid!==process.pid))];
}
async function stopLegacyLinkFieldServers() {
const pids=await legacyLinkFieldPids();if(!pids.length)return [];
for(const pid of pids)try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
const started=Date.now();while(Date.now()-started<3000&&pids.some(isProcessRunning))await sleep(100);
for(const pid of pids)if(isProcessRunning(pid))try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
return pids;
const stopped=new Set(),deadline=Date.now()+5_000;let quietSince=0;
while(Date.now()<deadline){
const active=await legacyLinkFieldPids(),pids=active.filter(pid=>!stopped.has(pid));
for(const pid of pids){
stopped.add(pid);
if(!await terminateProcess(pid,2_000))throw new Error(`Older LinkField process PID ${pid} did not stop.`);
}
if(active.length||pids.length)quietSince=0;else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=1_500)break;
await sleep(pids.length?300:150);
}
const remaining=await legacyLinkFieldPids();
if(remaining.length)throw new Error(`Older LinkField process PID ${remaining.join(', ')} kept restarting and could not be stopped.`);
return[...stopped];
}
function isProcessRunning(pid) {
@ -83,25 +146,85 @@ function isProcessRunning(pid) {
}
}
async function readPid() {
async function readServiceRecord() {
try {
const pid = Number((await fsp.readFile(PID_FILE, 'utf8')).trim());
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
const raw=(await fsp.readFile(PID_FILE, 'utf8')).trim();
if(/^\d+$/.test(raw)){const pid=Number(raw);return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:null,legacy:true}:null}
const value=JSON.parse(raw),pid=Number(value?.pid);
return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:typeof value.nonce==='string'?value.nonce:null,root:value.root}:null;
} catch (error) {
if (error?.code === 'ENOENT') return null;
throw error;
return null;
}
}
async function readPid(){return(await readServiceRecord())?.pid||null}
async function readIdentity(){try{return JSON.parse(await fsp.readFile(IDENTITY_FILE,'utf8'))}catch(error){if(error?.code==='ENOENT')return null;return null}}
async function isManagedService(pid){
if(!isProcessRunning(pid))return false;const [identity,record]=await Promise.all([readIdentity(),readServiceRecord()]);
return Boolean(identity&&record&&record.pid===pid&&identity.pid===pid&&path.resolve(identity.root||'')===ROOT&&path.resolve(record.root||ROOT)===ROOT&&(!record.nonce||identity.nonce===record.nonce)&&Date.now()-Number(identity.heartbeatAt||0)<5000);
}
async function terminateProcess(pid,timeoutMs=5000){
if(!isProcessRunning(pid))return true;
try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH')throw error}
const started=Date.now();while(isProcessRunning(pid)&&Date.now()-started<timeoutMs)await sleep(100);
if(isProcessRunning(pid)){try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH')throw error}}
const killedAt=Date.now();while(isProcessRunning(pid)&&Date.now()-killedAt<2000)await sleep(50);
return !isProcessRunning(pid);
}
async function removeStalePid() {
const pid = await readPid();
if (pid && isProcessRunning(pid)) return pid;
if (pid && await isManagedService(pid)) return pid;
const identity=await readIdentity();
if(isFreshRuntimeRecord(identity,5_000)&&typeof identity.nonce==='string'&&identity.nonce){
await fsp.writeFile(PID_FILE,`${JSON.stringify({pid:Number(identity.pid),nonce:identity.nonce,root:ROOT})}\n`,{encoding:'utf8',mode:0o600});
return Number(identity.pid);
}
await fsp.unlink(PID_FILE).catch(error => {
if (error?.code !== 'ENOENT') throw error;
});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
return null;
}
async function recoverWorldLock(){
await fsp.mkdir(SERVICE_ROOT,{recursive:true,mode:0o700});
const stopped=new Set(),deadline=Date.now()+15_000;let quietSince=0;
while(Date.now()<deadline){
const candidates=new Set(await legacyLinkFieldPids()),identity=await readIdentity(),lock=await readWorldInstanceLock();
if(identity&&Date.now()-Number(identity.heartbeatAt||0)<5_000&&await processExecutableIsNode(Number(identity.pid)))candidates.add(Number(identity.pid));
const lockPid=Number(lock?.pid);
if(Number.isSafeInteger(lockPid)&&lockPid>0&&isProcessRunning(lockPid)){
if(!await processExecutableIsNode(lockPid))throw new Error(`Refusing recovery: world-lock PID ${lockPid} is not a verified Node process.`);
candidates.add(lockPid);
}else if(lock){await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error})}
const active=[...candidates].filter(pid=>pid!==process.pid&&isProcessRunning(pid));
if(active.length){quietSince=0;for(const pid of active){if(!await terminateProcess(pid,3_000))throw new Error(`LinkField recovery could not stop PID ${pid}.`);stopped.add(pid)}}
else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=2_500)break;
await sleep(active.length?300:150);
}
const remaining=await readWorldInstanceLock(),remainingPid=Number(remaining?.pid);
if(Number.isSafeInteger(remainingPid)&&remainingPid>0&&isProcessRunning(remainingPid))throw new Error(`World-lock PID ${remainingPid} is still active after recovery.`);
await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(PID_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
console.log(stopped.size?`Recovered the LinkField world lock and stopped ${stopped.size} old process${stopped.size===1?'':'es'}.`:'Removed a stale LinkField world lock.');
console.log('Run npm start now.');
return[...stopped];
}
async function rotateLogs(){
let stat;try{stat=await fsp.stat(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;if(error)throw error}
if(!stat||stat.size<MAX_LOG_BYTES)return;
await fsp.unlink(`${LOG_FILE}.${LOG_GENERATIONS}`).catch(error=>{if(error?.code!=='ENOENT')throw error});
for(let index=LOG_GENERATIONS-1;index>=1;index--){try{await fsp.rename(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}}
await fsp.rename(LOG_FILE,`${LOG_FILE}.1`);
}
function isWithin(parent, child) {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
@ -126,7 +249,8 @@ function resolvePublicDir() {
}
async function copyEntry(source, destination) {
const stat = await fsp.stat(source);
const stat = await fsp.lstat(source);
if(stat.isSymbolicLink())return;
if (stat.isDirectory()) {
await fsp.mkdir(destination, {recursive:true, mode:0o755});
const entries = await fsp.readdir(source, {withFileTypes:true});
@ -141,25 +265,44 @@ async function copyEntry(source, destination) {
await fsp.chmod(destination, 0o644).catch(() => {});
}
function assertSafePublicDir(publicDir){
const resolved=path.resolve(publicDir),root=path.parse(resolved).root,home=path.resolve(os.homedir());
if(resolved===root||resolved===home||path.dirname(resolved)===resolved)throw new Error(`Unsafe LinkField public directory: ${resolved}`);
return resolved;
}
async function writeApacheBootstrap(publicDir){
const apacheFile=path.join(publicDir,'.htaccess');let existing='';
try{existing=await fsp.readFile(apacheFile,'utf8')}catch(error){if(error?.code!=='ENOENT')throw error}
await fsp.writeFile(apacheFile,replaceManagedBlock(existing,renderApacheBootstrap()),{encoding:'utf8',mode:0o644});
}
async function deployPublicFiles(publicDir = resolvePublicDir()) {
await fsp.mkdir(publicDir, {recursive:true, mode:0o755});
if (path.resolve(publicDir) !== ROOT) {
for (const entry of PUBLIC_ENTRIES) {
const source = path.join(ROOT, entry);
try {
await copyEntry(source, path.join(publicDir, entry));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
}
publicDir=assertSafePublicDir(publicDir);
const manifest = {
app: 'LinkField',
version: require('../build-meta').APP_VERSION,
source: ROOT,
deployedAt: new Date().toISOString(),
entries:[...PUBLIC_ENTRIES],
};
await fsp.writeFile(path.join(publicDir, '.linkfield-deployment.json'), `${JSON.stringify(manifest, null, 2)}\n`, {encoding:'utf8', mode:0o644});
if(path.resolve(publicDir)===ROOT){await writeApacheBootstrap(publicDir);await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});return publicDir}
const parent=path.dirname(publicDir),name=path.basename(publicDir),stamp=`${process.pid}-${Date.now()}`,stage=path.join(parent,`.${name}.linkfield-stage-${stamp}`),backup=path.join(parent,`.${name}.linkfield-old-${stamp}`);
await fsp.mkdir(parent,{recursive:true,mode:0o755});
const artifacts=(await fsp.readdir(parent,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&(entry.name.startsWith(`.${name}.linkfield-stage-`)||entry.name.startsWith(`.${name}.linkfield-old-`))).map(entry=>path.join(parent,entry.name)).sort();
if(!fs.existsSync(publicDir)){const recoverable=[...artifacts].reverse().find(item=>path.basename(item).startsWith(`.${name}.linkfield-old-`));if(recoverable){await fsp.rename(recoverable,publicDir);artifacts.splice(artifacts.indexOf(recoverable),1)}}
for(const artifact of artifacts)await fsp.rm(artifact,{recursive:true,force:true});
try{
if(fs.existsSync(publicDir))await copyEntry(publicDir,stage);else await fsp.mkdir(stage,{recursive:true,mode:0o755});
let previous=null;try{previous=JSON.parse(await fsp.readFile(path.join(stage,'.linkfield-deployment.json'),'utf8'))}catch(_){}
for(const retired of Array.isArray(previous?.entries)?previous.entries.filter(entry=>!PUBLIC_ENTRIES.includes(entry)):[]){const target=path.resolve(stage,retired);if(target.startsWith(`${stage}${path.sep}`))await fsp.rm(target,{recursive:true,force:true})}
for(const entry of PUBLIC_ENTRIES){const source=path.join(ROOT,entry),destination=path.join(stage,entry);await fsp.rm(destination,{recursive:true,force:true});try{await copyEntry(source,destination)}catch(error){if(error?.code!=='ENOENT')throw error}}
await writeApacheBootstrap(stage);
await fsp.writeFile(path.join(stage,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});
if(fs.existsSync(publicDir))await fsp.rename(publicDir,backup);
try{await fsp.rename(stage,publicDir)}catch(error){if(fs.existsSync(backup)&&!fs.existsSync(publicDir))await fsp.rename(backup,publicDir);throw error}
await fsp.rm(backup,{recursive:true,force:true});
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
return publicDir;
}
@ -172,6 +315,15 @@ async function tailLog(lines = 20) {
}
}
function localStatus(port){
return new Promise((resolve,reject)=>{const request=http.get({hostname:'127.0.0.1',port,path:'/api/cloud/status',timeout:1500,headers:{connection:'close'}},response=>{let body='';response.setEncoding('utf8');response.on('data',chunk=>{if(body.length<65536)body+=chunk});response.on('end',()=>{try{const value=JSON.parse(body);if(response.statusCode===200&&value?.available===true&&value?.appVersion===require('../build-meta').APP_VERSION)resolve(value);else reject(new Error(`Local health returned HTTP ${response.statusCode}`))}catch(error){reject(error)}})});request.on('timeout',()=>request.destroy(new Error('Local health timed out')));request.on('error',reject)});
}
async function publicStatus(publicUrl){
const result=await checkPublicDeployment(publicUrl,{appVersion:require('../build-meta').APP_VERSION,timeoutMs:10_000});return result?.api||null;
}
function configuredPublicUrl(){return String(process.env.LINK_FIELD_PUBLIC_URL||'').trim()}
async function waitForStartup(pid, publicDir) {
const portFile = path.join(publicDir, '.linkfield-port');
const started = Date.now();
@ -182,7 +334,7 @@ async function waitForStartup(pid, publicDir) {
}
try {
const port = Number((await fsp.readFile(portFile, 'utf8')).trim());
if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return port;
if (Number.isSafeInteger(port) && port > 0 && port <= 65535&&await isManagedService(pid)){try{await localStatus(port);return port}catch(_){}}
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
@ -195,41 +347,52 @@ async function waitForStartup(pid, publicDir) {
async function start() {
await fsp.mkdir(SERVICE_ROOT, {recursive:true, mode:0o700});
const existingPid = await removeStalePid();
let publicDir=null;
if (existingPid) {
publicDir = await deployPublicFiles();
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
await stop({quiet:true});
}
const stoppedLegacy=await stopLegacyLinkFieldServers();
const publicDir = await deployPublicFiles();
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
const unresolvedLock=await readWorldInstanceLock(),unresolvedPid=Number(unresolvedLock?.pid);
if(Number.isSafeInteger(unresolvedPid)&&unresolvedPid>0&&isProcessRunning(unresolvedPid))throw new Error(`A legacy LinkField world lock is still held by PID ${unresolvedPid}. Run npm run recover once, then run npm start again.`);
if(!publicDir)publicDir = await deployPublicFiles();
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.mkdir(path.dirname(LOG_FILE), {recursive:true, mode:0o755});
await rotateLogs();
const logFd = fs.openSync(LOG_FILE, 'a');
let child;
const nonce=crypto.randomBytes(16).toString('hex');
try {
child = spawn(process.execPath, [SERVER_FILE], {
child = spawn(process.execPath, [RUNNER_FILE], {
cwd: ROOT,
detached: true,
stdio: ['ignore', logFd, logFd],
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT},
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT,LINK_FIELD_SERVICE_IDENTITY_FILE:IDENTITY_FILE,LINK_FIELD_SERVICE_NONCE:nonce,LINK_FIELD_LOG_FILE:LOG_FILE},
});
} finally {
fs.closeSync(logFd);
}
if (!child.pid) throw new Error('Could not start the LinkField background process.');
await fsp.writeFile(PID_FILE, `${child.pid}\n`, {encoding:'utf8', mode:0o600});
child.unref();
try {
await fsp.writeFile(PID_FILE, `${JSON.stringify({pid:child.pid,nonce,root:ROOT})}\n`, {encoding:'utf8', mode:0o600});
child.unref();
const port = await waitForStartup(child.pid, publicDir);
const publicEndpoint=await publicStatus(configuredPublicUrl());
console.log('LinkField started in the background. The command prompt is available again.');
console.log(`PID: ${child.pid}`);
console.log(`Local port: ${port}`);
console.log(`Public directory: ${publicDir}`);
console.log(`Log: ${LOG_FILE}`);
console.log("Check: curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'");
if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);
console.log(`Check: ${process.execPath} scripts/public-smoke-test.js https://YOUR-HOST/~333/link-field/`);
} catch (error) {
const stopped=!child?.pid||await terminateProcess(child.pid).catch(()=>false);
if(!stopped)throw new Error(`${error.message}\nLinkField supervisor PID ${child.pid} could not be stopped; its PID record was preserved for a safe manual stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(()=>{});
throw error;
}
}
@ -238,21 +401,16 @@ async function stop({quiet = false} = {}) {
const pid = await readPid();
if (!pid || !isProcessRunning(pid)) {
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log('LinkField is not running.');
return false;
}
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if (error?.code !== 'ESRCH') throw error;
}
const started = Date.now();
while (isProcessRunning(pid) && Date.now() - started < 5000) await sleep(100);
if (isProcessRunning(pid)) {
try { process.kill(pid, 'SIGKILL'); }
catch (error) { if (error?.code !== 'ESRCH') throw error; }
}
if(!await isManagedService(pid))throw new Error(`Refusing to stop PID ${pid}: it is not a verified LinkField supervisor.`);
if(!await terminateProcess(pid))throw new Error(`LinkField supervisor PID ${pid} did not stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
return true;
}
@ -260,7 +418,7 @@ async function stop({quiet = false} = {}) {
async function status() {
const pid = await readPid();
const publicDir = resolvePublicDir();
if (!pid || !isProcessRunning(pid)) {
if (!pid || !await isManagedService(pid)) {
console.log('LinkField is stopped.');
process.exitCode = 1;
return;
@ -268,9 +426,10 @@ async function status() {
let port = '';
try { port = (await fsp.readFile(path.join(publicDir, '.linkfield-port'), 'utf8')).trim(); }
catch {}
console.log(`LinkField is running (PID ${pid}${port ? `, port ${port}` : ''}).`);
let healthy=false,publicEndpoint=null;if(port)try{await localStatus(Number(port));publicEndpoint=await publicStatus(configuredPublicUrl());healthy=true}catch(_){}
console.log(`LinkField is ${healthy?'healthy':'unhealthy'} (supervisor PID ${pid}${port ? `, port ${port}` : ''}).`);
console.log(`Public directory: ${publicDir}`);
console.log(`Log: ${LOG_FILE}`);
console.log(`Log: ${LOG_FILE}`);if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);if(!healthy)process.exitCode=1;
}
async function foreground() {
@ -281,6 +440,11 @@ async function foreground() {
console.log(`LinkField is running in the foreground. Public directory: ${publicDir}`);
}
async function assertServiceStopped(){const pid=await readPid();if(pid&&await isManagedService(pid))throw new Error('Stop LinkField before running an offline backup or restore.');try{const raw=(await fsp.readFile(path.join(WORLD_DIR,'server.pid'),'utf8')).trim(),lock=/^\d+$/.test(raw)?{pid:Number(raw)}:JSON.parse(raw);if(isProcessRunning(Number(lock?.pid)))throw new Error('A LinkField world process is still active. Stop it before backup or restore.')}catch(error){if(error?.code!=='ENOENT'&&!/Unexpected token|Unexpected end/.test(error?.message||''))throw error}}
async function backup(){await assertServiceStopped();const result=await createWorldBackup(WORLD_DIR,BACKUP_DIR,{retain:7,appVersion:require('../build-meta').APP_VERSION});console.log(`LinkField backup created: ${result.destination}`);return result}
async function listBackups(){await fsp.mkdir(BACKUP_DIR,{recursive:true,mode:0o700});const names=(await fsp.readdir(BACKUP_DIR,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();for(const name of names){try{const result=await verifyWorldBackup(BACKUP_DIR,name);console.log(`${name} revision=${result.manifest.worldRevision} files=${result.manifest.fileCount}`)}catch(error){console.log(`${name} INVALID ${error.message}`)}}return names}
async function restore(name){await assertServiceStopped();if(!name)throw new Error('Specify a backup name from npm run backups.');const result=await restoreWorldBackup(WORLD_DIR,BACKUP_DIR,name);console.log(`LinkField restored backup ${result.restored}. Previous data remains at ${result.previous}`);return result}
async function main() {
const command = String(process.argv[2] || 'start').toLowerCase();
if (command === 'start') return start();
@ -288,6 +452,10 @@ async function main() {
if (command === 'restart') { await stop({quiet:true}); return start(); }
if (command === 'status') return status();
if (command === 'foreground') return foreground();
if (command === 'backup') return backup();
if (command === 'backups') return listBackups();
if (command === 'restore') return restore(process.argv[3]);
if (command === 'recover') return recoverWorldLock();
if (command === 'deploy') {
const publicDir = await deployPublicFiles();
console.log(`LinkField public files deployed to ${publicDir}`);
@ -296,7 +464,7 @@ async function main() {
throw new Error(`Unknown service command: ${command}`);
}
module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, LOG_FILE, PUBLIC_ENTRIES, isProcessRunning, resolvePublicDir, deployPublicFiles, start, stop, status});
module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, IDENTITY_FILE, LOG_FILE, WORLD_DIR, BACKUP_DIR, PUBLIC_ENTRIES, isProcessRunning,legacyLinkFieldPids,recoverWorldLock,resolvePublicDir,deployPublicFiles,start,stop,status,backup,listBackups,restore});
if (require.main === module) main().catch(error => {
console.error(`LinkField service command failed: ${error.message}`);
process.exitCode = 1;

70
scripts/service-runner.js Normal file
View file

@ -0,0 +1,70 @@
'use strict';
const fs=require('fs');
const path=require('path');
const {spawn}=require('child_process');
const ROOT=path.resolve(__dirname,'..');
const SERVER_FILE=path.join(ROOT,'server.js');
const IDENTITY_FILE=path.resolve(process.env.LINK_FIELD_SERVICE_IDENTITY_FILE||path.join(process.env.LINK_FIELD_SERVICE_DIR||ROOT,'service.identity.json'));
const NONCE=String(process.env.LINK_FIELD_SERVICE_NONCE||'');
const LOG_FILE=String(process.env.LINK_FIELD_LOG_FILE||'');
const PORT_FILE=path.join(String(process.env.LINK_FIELD_PUBLIC_DIR||ROOT),'.linkfield-port');
const RESTART_WINDOW_MS=5*60*1000;
const MAX_RESTARTS=10;
const MAX_LOG_BYTES=Math.max(4096,Number(process.env.LINK_FIELD_LOG_MAX_BYTES)||10*1024*1024);
const LOG_ROTATE_INTERVAL_MS=Math.max(100,Number(process.env.LINK_FIELD_LOG_ROTATE_INTERVAL_MS)||30_000);
const LOG_GENERATIONS=5;
let child=null,stopping=false,restarts=[];
function writeIdentity(){
const value={pid:process.pid,root:ROOT,nonce:NONCE,heartbeatAt:Date.now(),childPid:child?.pid||null};
const temporary=`${IDENTITY_FILE}.${process.pid}.tmp`;
fs.mkdirSync(path.dirname(IDENTITY_FILE),{recursive:true,mode:0o700});
fs.writeFileSync(temporary,`${JSON.stringify(value)}\n`,{encoding:'utf8',mode:0o600});
fs.renameSync(temporary,IDENTITY_FILE);
}
function removeIdentity(){
try{const value=JSON.parse(fs.readFileSync(IDENTITY_FILE,'utf8'));if(value?.pid===process.pid&&value?.nonce===NONCE)fs.unlinkSync(IDENTITY_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Service identity cleanup warning: ${error.message}`)}
try{fs.unlinkSync(PORT_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Bridge-port cleanup warning: ${error.message}`)}
}
function rotateLiveLog(){
if(!LOG_FILE)return;
let stat;try{stat=fs.statSync(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;throw error}
if(stat.size<MAX_LOG_BYTES)return;
try{fs.unlinkSync(`${LOG_FILE}.${LOG_GENERATIONS}`)}catch(error){if(error?.code!=='ENOENT')throw error}
for(let index=LOG_GENERATIONS-1;index>=1;index--)try{fs.renameSync(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}
fs.copyFileSync(LOG_FILE,`${LOG_FILE}.1`);fs.truncateSync(LOG_FILE,0);
}
function startChild(){
if(stopping)return;
const now=Date.now();restarts=restarts.filter(value=>now-value<RESTART_WINDOW_MS);
if(restarts.length>=MAX_RESTARTS){console.error(`LinkField stopped after ${MAX_RESTARTS} crashes in five minutes.`);removeIdentity();process.exit(1);return}
restarts.push(now);
child=spawn(process.execPath,[SERVER_FILE],{cwd:ROOT,stdio:'inherit',env:{...process.env,LINK_FIELD_SUPERVISED:'1'}});
writeIdentity();
child.once('exit',(code,signal)=>{
const lifetime=Date.now()-now;child=null;writeIdentity();
if(stopping){removeIdentity();process.exit(0);return}
console.error(`LinkField server exited (${signal||code}); restarting.`);
if(lifetime>60_000)restarts=[];
setTimeout(startChild,Math.min(10_000,500*Math.max(1,restarts.length)));
});
}
function shutdown(signal){
if(stopping)return;stopping=true;console.log(`LinkField supervisor received ${signal}; stopping.`);
if(child&&child.exitCode==null){child.kill('SIGTERM');const timer=setTimeout(()=>{if(child&&child.exitCode==null)child.kill('SIGKILL')},5000);timer.unref?.()}
else{removeIdentity();process.exit(0)}
}
process.once('SIGTERM',()=>shutdown('SIGTERM'));
process.once('SIGINT',()=>shutdown('SIGINT'));
process.once('exit',removeIdentity);
const heartbeat=setInterval(()=>{try{writeIdentity()}catch(error){console.error(`Service heartbeat failed: ${error.message}`)}},1000);heartbeat.unref?.();
const logRotation=setInterval(()=>{try{rotateLiveLog()}catch(error){console.error(`Live log rotation failed: ${error.message}`)}},LOG_ROTATE_INTERVAL_MS);logRotation.unref?.();
startChild();

211
server.js
View file

@ -12,6 +12,7 @@ const { createAuthenticator } = require('./server/auth');
const { createJsonRepository } = require('./server/json-repository');
const { installApacheBridge } = require('./server/apache-bridge');
const { createPlayerService } = require('./server/player-service');
const { createWorldBackup } = require('./server/world-backup');
const BuildMeta = require('./build-meta');
const SharedContracts = require('./shared-contracts');
const AppLogic = require('./app-logic');
@ -21,9 +22,11 @@ const STARTER_LINE_COLOR_IDS=Object.freeze([...STORE_CATALOG.values()].filter(it
const ROOT = __dirname;
const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT);
const PRODUCTION_DATA_DIR = path.resolve('/link-field/world');
const PRODUCTION_DATA_DIR = path.resolve(String(process.env.LINK_FIELD_WORLD_DIR || '/link-field/world'));
const TEST_DATA_ROOT = String(process.env.LINK_FIELD_TEST_DATA_ROOT || '').trim();
const DATA_DIR = TEST_DATA_ROOT ? path.join(path.resolve(TEST_DATA_ROOT), 'world') : PRODUCTION_DATA_DIR;
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(DATA_DIR),'backups')));
const BACKUPS_ENABLED=!TEST_DATA_ROOT&&!['0','false','off','no'].includes(String(process.env.LINK_FIELD_BACKUPS||'').trim().toLowerCase());
const INSTANCE_LOCK_FILE = path.join(DATA_DIR, 'server.pid');
const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json');
const WORLD_COMMIT_FILE = path.join(DATA_DIR, 'shared-world.commit.json');
@ -57,30 +60,63 @@ const PORT_SOURCE = CLI_PORT ?? process.env.LINK_FIELD_PORT ?? process.env.PORT;
const PORT_EXPLICIT = PORT_SOURCE != null && String(PORT_SOURCE).trim() !== '';
const PORT_STRICT = process.argv.includes('--strict-port') || ['1','true','on','yes'].includes(String(process.env.LINK_FIELD_STRICT_PORT || '').trim().toLowerCase());
const PORT = parsePort(PORT_SOURCE, DEFAULT_PORT);
const APACHE_BRIDGE_ENABLED = !['0','false','off','no'].includes(String(process.env.LINK_FIELD_APACHE_BRIDGE || '').trim().toLowerCase());
const PUBLIC_BRIDGE_PORT_FILE = path.join(PUBLIC_ROOT, '.linkfield-port');
const MAX_BODY_BYTES = 64 * 1024 * 1024;
const MAX_BOARDS_PER_PUSH = 10000;
const MAX_PATHS_PER_BOARD = 512;
const MAX_CELLS_PER_PATH = 5000;
const CLOUD_PAGE_LIMIT = 512;
const APACHE_BRIDGE_SETTING=String(process.env.LINK_FIELD_APACHE_BRIDGE||'').trim().toLowerCase();
const APACHE_BRIDGE_ENABLED = TEST_DATA_ROOT ? ['1','true','on','yes'].includes(APACHE_BRIDGE_SETTING) : !['0','false','off','no'].includes(APACHE_BRIDGE_SETTING);
const PUBLIC_BRIDGE_PORT_FILE = TEST_DATA_ROOT&&!process.env.LINK_FIELD_PUBLIC_DIR?path.join(path.resolve(TEST_DATA_ROOT),'.linkfield-port'):path.join(PUBLIC_ROOT, '.linkfield-port');
const MAX_BODY_BYTES = 8 * 1024 * 1024;
const MAX_BOARDS_PER_PUSH = 16;
const MAX_METAS_PER_PUSH = 8;
const MAX_PATHS_PER_BOARD = 128;
const MAX_CELLS_PER_PATH = 1250;
const CLOUD_PAGE_LIMIT = 64;
const CLOUD_READ_CONCURRENCY = 16;
const CHANGE_HISTORY_LIMIT = 256;
const CLEAR_EVENT_LIMIT = 64;
const MAX_PLAYER_PURCHASES = 10000;
const MAX_PLAYER_RECORDS = 100000;
const GENERATION_FAILURE_BONUS = 2500;
const GENERATION_FAILURE_MIN_DELAY_MS = 8000;
const PLAYER_RE = /^[a-f0-9]{16,64}$/i;
const TOKEN_RE = /^[a-f0-9]{32,128}$/i;
const MUTATION_RE=/^[A-Za-z0-9_-]{8,96}$/;
const BOARD_RE = SharedContracts.BOARD_ID_RE;
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.ttf': 'font/ttf', '.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
};
const PUBLIC_STATIC_ROOTS=new Set(['index.html','style.css','favicon.svg','favicon.ico','build-meta.js','runtime-config.js','shared-contracts.js','store-catalog.generated.js','store-catalog.json','puzzle-patterns.js','puzzle-core.js','app-logic.js','archive-codec.js','field-persistence.js','field-persistence-worker.js','puzzle-worker.js','app.js','assets','client']);
let worldQueue = Promise.resolve();
let worldCache=null;
const playerQueues = new Map();
let playerCapacityQueue=Promise.resolve();
let realtimeHub = null;
let playerRecordCount = null;
let instanceLockToken=null,instanceLockHeartbeatTimer=null;
const jsonRepository = createJsonRepository({fsp,crypto});
const rateWindows = new Map();
function rateLimit(key,limit,windowMs,cost=1){
const now=serverTime(),normalized=String(key||'unknown'),current=rateWindows.get(normalized);
if(rateWindows.size>10000){for(const[item,value]of rateWindows)if(value.resetAt<=now)rateWindows.delete(item);if(rateWindows.size>20000)throw Object.assign(new Error('Request limiter capacity has been reached'),{status:503})}
cost=Math.max(1,Math.floor(Number(cost)||1));
if(!current||current.resetAt<=now){if(cost<=limit){rateWindows.set(normalized,{count:cost,resetAt:now+windowMs});return true}const error=Object.assign(new Error('Too many requests'),{status:429,retryAfter:Math.max(1,Math.ceil(windowMs/1000))});throw error}
current.count+=cost;
if(current.count<=limit)return true;
const error=Object.assign(new Error('Too many requests'),{status:429,retryAfter:Math.max(1,Math.ceil((current.resetAt-now)/1000))});
throw error;
}
function requestIp(req){
const direct=String(req?.socket?.remoteAddress||'unknown').replace(/^::ffff:/,'');
if(!['127.0.0.1','::1'].includes(direct))return direct;
const forwarded=String(req?.headers?.['x-forwarded-for']||'').split(',').at(-1).trim();
return /^[0-9a-f:.]{3,64}$/i.test(forwarded)?forwarded:direct;
}
async function assertPlayerCapacity(){
if(playerRecordCount==null){let names=[];try{names=await fsp.readdir(DATA_DIR)}catch(error){if(error.code!=='ENOENT')throw error}playerRecordCount=names.filter(name=>/^[a-f0-9]{16,64}\.json$/i.test(name)).length}
if(playerRecordCount>=MAX_PLAYER_RECORDS)throw Object.assign(new Error('Player capacity has been reached'),{status:503});
}
function withPlayerCapacity(task){const run=playerCapacityQueue.then(task,task);playerCapacityQueue=run.then(()=>undefined,()=>undefined);return run}
function json(res, status, value) {
const body = JSON.stringify(value);
@ -116,13 +152,14 @@ async function readPlayer(playerId){
try{const value=JSON.parse(await fsp.readFile(playerPath(playerId),'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid player record');value.name=cleanPlayerName(value.name,defaultPlayerName(playerId));value.purchases=normalizePlayerPurchases(value.purchases);value.generationBonuses=normalizeGenerationBonuses(value.generationBonuses);value.economyRevision=Number.isSafeInteger(value.economyRevision)?value.economyRevision:0;value.earnedScore=playerEarnedScore(value);value.starterLineColor=normalizeStarterLineColor(value.starterLineColor,playerId);return value}
catch(error){if(error.code==='ENOENT')throw Object.assign(new Error('Cloud profile not found'),{status:404});throw error}
}
function emptyWorld(){const now=serverTime();return{revision:0,rowRevision:now*1000,expansionGrants:{},global:{schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},occupancy:{},changes:[],clearEvents:[],createdAt:now,updatedAt:now}}
function emptyWorld(){const now=serverTime();return{revision:0,rowRevision:now*1000,expansionGrants:{},recentMutations:[],global:{schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},occupancy:{},changes:[],clearEvents:[],clearEventsDroppedThrough:0,createdAt:now,updatedAt:now}}
async function readWorld(){
if(worldCache)return worldCache;
try{
const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');
if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)return emptyWorld();
value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};return value
}catch(error){if(error.code==='ENOENT')return emptyWorld();throw error}
if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)throw Object.assign(new Error(`Stored world generation ${String(value.global?.worldGeneration||'missing')} does not match ${BuildMeta.WORLD_GENERATION}; restore or migrate the world before startup.`),{status:503,code:'EWORLDGENERATION'});
value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.recentMutations=Array.isArray(value.recentMutations)?value.recentMutations.slice(-1024):[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.clearEventsDroppedThrough=Number.isSafeInteger(value.clearEventsDroppedThrough)?value.clearEventsDroppedThrough:0;value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};worldCache=value;return value
}catch(error){if(error.code==='ENOENT'){worldCache=emptyWorld();return worldCache}throw error}
}
async function readWorldBoard(record,id){const revision=record.boardVersions[id];if(!Number.isSafeInteger(revision))return null;try{const value=JSON.parse(await fsp.readFile(worldBoardVersionPath(id,revision),'utf8'));return value&&typeof value==='object'?value:null}catch(error){if(error.code==='ENOENT')throw Object.assign(new Error(`Shared board is missing: ${id}`),{status:500});throw error}}
async function ensureWorldIndexes(record){
@ -137,7 +174,9 @@ function addMetaToWorldOccupancy(meta,occupancy,{requireTouch=false}={}){
if(!touches)badRequest(`Generated board is not adjacent: ${meta.id}`);for(const key of keys)occupancy[key]=meta.id;return true;
}
async function collectRetiredBoardVersions(){
const world=await readWorld();let names=[];try{names=await fsp.readdir(WORLD_BOARDS_DIR)}catch(error){if(error.code==='ENOENT')return 0;throw error}
let world;try{world=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'))}catch(error){if(error.code==='ENOENT')return 0;throw error}
if(!world||world.global?.worldGeneration!==BuildMeta.WORLD_GENERATION||!world.boardVersions||typeof world.boardVersions!=='object')return 0;
let names=[];try{names=await fsp.readdir(WORLD_BOARDS_DIR)}catch(error){if(error.code==='ENOENT')return 0;throw error}
let removed=0;
for(const name of names){
const match=/^(B(?:0|[1-9][0-9]*))\.([0-9]+)\.json$/.exec(name);if(!match)continue;
@ -146,6 +185,12 @@ async function collectRetiredBoardVersions(){
}
return removed;
}
async function removeRetiredBoardVersions(entries){
for(const entry of entries||[]){
if(!entry||!BOARD_RE.test(entry.id)||!Number.isSafeInteger(entry.revision)||entry.revision<0)continue;
await fsp.unlink(worldBoardVersionPath(entry.id,entry.revision)).catch(error=>{if(error.code!=='ENOENT')console.warn(`Retired board cleanup warning: ${error.message}`)});
}
}
async function recoverPendingWorldCommit(){
let commit;try{commit=JSON.parse(await fsp.readFile(WORLD_COMMIT_FILE,'utf8'))}catch(error){if(error.code==='ENOENT')return false;throw error}
if(!commit||!Number.isSafeInteger(commit.revision)||!commit.world||commit.world.revision!==commit.revision)throw new Error('Invalid pending shared-world commit');
@ -158,7 +203,7 @@ async function recoverPendingWorldCommit(){
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return false;
}
if(commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await atomicWriteJson(WORLD_FILE,commit.world);
await atomicWriteJson(WORLD_FILE,commit.world);worldCache=commit.world;
}else if(current.revision===commit.revision&&commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return true;
}
@ -166,13 +211,13 @@ async function commitWorldMutation(world,{nextPlayer=null,previousPlayer=null,ch
const commit={revision:world.revision,world,nextPlayer,previousPlayer,changedBoardIds:[...changedBoardIds],preparedAt:serverTime()};
await atomicWriteJson(WORLD_COMMIT_FILE,commit);
if(nextPlayer)await writePlayerRecord(nextPlayer);
await atomicWriteJson(WORLD_FILE,world);
await atomicWriteJson(WORLD_FILE,world);worldCache=world;
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});
}
const authenticator=createAuthenticator({playerPattern:PLAYER_RE,tokenPattern:TOKEN_RE,readPlayer,hashToken:tokenHash,safeEqual:safeEqualHex});
function authenticate(req){return authenticator.parse(req)}
async function authenticatedPlayer(req){return authenticator.player(req)}
async function readJsonBody(req){const chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
async function readJsonBody(req){const declared=Number(req.headers?.['content-length']);if(Number.isFinite(declared)&&declared>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});const chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES){req.destroy();throw Object.assign(new Error('Request body is too large'),{status:413})}chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
function cellKey(cell){return `${cell[0]},${cell[1]}`}
function normalizedRouteKey(cells){const forward=cells.map(cellKey).join('|'),reverse=[...cells].reverse().map(cellKey).join('|');return forward<reverse?forward:reverse}
function validatePuzzle(meta){
@ -213,10 +258,11 @@ function solvedStateMatchesPuzzle(state,puzzle){
}
function validateMeta(meta){
if(!meta||typeof meta!=='object'||!BOARD_RE.test(meta.id))badRequest('Invalid board metadata');const chunks=Array.isArray(meta.chunks)?meta.chunks:[];
if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||Math.abs(cell[0])>1000000||Math.abs(cell[1])>1000000||seen.has(cellKey(cell)))badRequest(`Invalid chunk coordinate for ${meta.id}`);seen.add(cellKey(cell))}
if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||cell[0]<0||cell[1]<0||cell[0]>64||cell[1]>64||seen.has(cellKey(cell)))badRequest(`Invalid chunk coordinate for ${meta.id}`);seen.add(cellKey(cell))}
if(Math.min(...chunks.map(cell=>cell[0]))!==0||Math.min(...chunks.map(cell=>cell[1]))!==0)badRequest(`Unanchored chunks for ${meta.id}`);const reached=new Set([cellKey(chunks[0])]),queue=[chunks[0]];while(queue.length){const[x,y]=queue.shift();for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const key=`${x+dx},${y+dy}`;if(seen.has(key)&&!reached.has(key)){reached.add(key);queue.push([x+dx,y+dy])}}}if(reached.size!==seen.size)badRequest(`Disconnected chunks for ${meta.id}`);
if(!Number.isSafeInteger(meta.x)||!Number.isSafeInteger(meta.y)||Math.abs(meta.x)>100000000||Math.abs(meta.y)>100000000)badRequest(`Invalid board coordinate for ${meta.id}`);
if(!Number.isSafeInteger(meta.seed)||meta.seed<0||meta.seed>0xffffffff)badRequest(`Invalid seed for ${meta.id}`);
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);const sectionRange=AppLogic.sectionCountRange(meta.targetLevel);if(chunks.length<sectionRange.min||chunks.length>sectionRange.max||meta.level<=3&&chunks.length!==1)badRequest(`Invalid section count for ${meta.id}`);
if(meta.entrySide!=null&&!['N','S','W','E'].includes(meta.entrySide))badRequest(`Invalid entry side for ${meta.id}`);
if(meta.sealedSides!=null&&(!Array.isArray(meta.sealedSides)||meta.sealedSides.some(side=>!['N','S','W','E'].includes(side))))badRequest(`Invalid sealed sides for ${meta.id}`);
const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean);
@ -224,24 +270,30 @@ function validateMeta(meta){
clean.level=derivedLevel;clean.puzzle.level=derivedLevel;clean.puzzle.difficulty=derivedLevel;clean.puzzle.complexity=PuzzleCore.solutionComplexity(clean.puzzle);clean.puzzle.interactionBurden=AppLogic.interactionBurden(clean.puzzle);
delete clean.puzzle.solutionQuality;delete clean.puzzle.uniqueness;
if(clean.targetLevel>=6){
const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:2000,nodeCap:150000,analyzeQuality:false});
const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:1000,nodeCap:100000,analyzeQuality:false});
if(verification.status!=='unique'||verification.signature!==PuzzleCore.puzzleSignature(clean.puzzle))badRequest(`Puzzle is not server-verified: ${clean.id}`);
clean.puzzle.uniqueness={status:'unique',signature:verification.signature,ruleVersion:verification.ruleVersion,nodes:verification.nodes};
}
return clean;
const source=clean.puzzle,bounds=source.bounds&&Number.isInteger(source.bounds.w)&&Number.isInteger(source.bounds.h)&&source.bounds.w>0&&source.bounds.w<=325&&source.bounds.h>0&&source.bounds.h<=325?{w:source.bounds.w,h:source.bounds.h}:null;
clean.puzzle={g:source.g,n:source.n,valid:source.valid,obstacles:Array.isArray(source.obstacles)?source.obstacles:[],specialCells:{crossings:Array.isArray(source.specialCells?.crossings)?source.specialCells.crossings:[],warps:Array.isArray(source.specialCells?.warps)?source.specialCells.warps:[],locks:Array.isArray(source.specialCells?.locks)?source.specialCells.locks:[],internalGates:Array.isArray(source.specialCells?.internalGates)?source.specialCells.internalGates:[]},bounds,axis:typeof source.axis==='string'?source.axis.slice(0,16):'MIX',solution:source.solution,level:derivedLevel,difficulty:derivedLevel,maxTurns:Number.isFinite(source.maxTurns)?source.maxTurns:0,totalTurns:Number.isFinite(source.totalTurns)?source.totalTurns:0,style:typeof source.style==='string'?source.style.slice(0,32):'stored-procedural',complexity:source.complexity,regionalTarget:Number.isInteger(source.regionalTarget)?source.regionalTarget:clean.targetLevel,regionalFallback:source.regionalFallback===true,regionalOutlier:source.regionalOutlier===true,uniqueness:source.uniqueness||null,interactionBurden:source.interactionBurden};
return{id:clean.id,x:clean.x,y:clean.y,chunks:clean.chunks.map(cell=>[...cell]),level:derivedLevel,targetLevel:clean.targetLevel,seed:clean.seed,axis:clean.puzzle.axis,entrySide:['N','S','W','E'].includes(clean.entrySide)?clean.entrySide:null,sealedSides:[...new Set((clean.sealedSides||[]).filter(side=>['N','S','W','E'].includes(side)))],puzzle:clean.puzzle,generatorVersion:BuildMeta.GENERATOR_VERSION};
}
function validateStateRow(row,meta){
if(!row||typeof row!=='object'||!BOARD_RE.test(row.id)||!row.value||typeof row.value!=='object')badRequest('Invalid board state');if(!meta)badRequest(`State without metadata: ${row.id}`);
const state=JSON.parse(JSON.stringify(row.value)),puzzle=validatePuzzle(meta),paths=Array.isArray(state.paths)?state.paths:[];if(paths.length>MAX_PATHS_PER_BOARD)badRequest(`Too many paths for ${row.id}`);
for(const item of paths){if(!item||typeof item!=='object'||!Array.isArray(item.cells)||!item.cells.length||item.cells.length>MAX_CELLS_PER_PATH)badRequest(`Invalid path for ${row.id}`);if(!Number.isInteger(item.startGate)||item.startGate<0||item.startGate>=puzzle.gates.length||item.endGate!=null&&(!Number.isInteger(item.endGate)||item.endGate<0||item.endGate>=puzzle.gates.length))badRequest(`Invalid path gates for ${row.id}`);if(item.detachedStart!==true&&!sameCellValue(item.cells[0],puzzle.gates[item.startGate])||item.endGate!=null&&!sameCellValue(item.cells[item.cells.length-1],puzzle.gates[item.endGate]))badRequest(`Path endpoint mismatch for ${row.id}`);const seen=new Set();for(let index=0;index<item.cells.length;index++){const cell=item.cells[index];if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1]))badRequest(`Invalid path cell for ${row.id}`);const key=cellKey(cell);if(!puzzle.valid.has(key)||seen.has(key))badRequest(`Invalid path cell for ${row.id}`);seen.add(key);if(index){const previous=item.cells[index-1],distance=Math.abs(previous[0]-cell[0])+Math.abs(previous[1]-cell[1]);if(distance!==1&&puzzle.warpMap.get(cellKey(previous))!==key)badRequest(`Disconnected path for ${row.id}`)}}}
if(state.solved===true&&!solvedStateMatchesPuzzle(state,puzzle))badRequest(`Solved state does not satisfy puzzle rules for ${row.id}`)
return state;
const cleanPaths=paths.map(item=>({startGate:item.startGate,endGate:item.endGate??null,openGate:Number.isInteger(item.openGate)?item.openGate:null,detachedStart:item.detachedStart===true&&item.endGate==null,cells:item.cells.map(cell=>[...cell]),colorIndex:Number.isInteger(item.colorIndex)&&item.colorIndex>=0&&item.colorIndex<64?item.colorIndex:0,startColorIndex:Number.isInteger(item.startColorIndex)&&item.startColorIndex>=0&&item.startColorIndex<64?item.startColorIndex:0,endColorIndex:Number.isInteger(item.endColorIndex)&&item.endColorIndex>=0&&item.endColorIndex<64?item.endColorIndex:null,lineEffect:typeof item.lineEffect==='string'&&item.lineEffect.length<=64?item.lineEffect:null,ownerId:PLAYER_RE.test(String(item.ownerId||''))?String(item.ownerId).toLowerCase():null}));
const crossings=[...new Set((Array.isArray(state.specialProgress?.crossings)?state.specialProgress.crossings:[]).filter(value=>typeof value==='string'&&/^-?\d+,-?\d+$/.test(value)))].slice(0,64);
return{paths:cleanPaths,specialProgress:{crossings},solved:state.solved===true,expanded:state.expanded===true,expansionRetryRound:Number.isInteger(state.expansionRetryRound)&&state.expansionRetryRound>=0?Math.min(state.expansionRetryRound,1000000):0,scoreVersion:Number.isInteger(state.scoreVersion)&&state.scoreVersion>=0?Math.min(state.scoreVersion,1000):0};
}
function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta));delete clean.rev;delete clean.revAuthor;delete clean.sealedSides;return JSON.stringify(clean)}
function sanitizeWorldGlobal(value,record){
const source=value&&typeof value==='object'&&!Array.isArray(value)?value:{},prior=record?.global||{},clean={};
for(const key of['schema','gameplayVersion','worldGeneration','worldEpoch','appVersion','generatorVersion','nextId','solved','lastSolveAt','specialMechanicsSeen','quarantine'])if(Object.prototype.hasOwnProperty.call(source,key))clean[key]=JSON.parse(JSON.stringify(source[key]));
if(Number.isInteger(source.gameplayVersion)&&source.gameplayVersion>=0&&source.gameplayVersion<=1000)clean.gameplayVersion=source.gameplayVersion;
if(Number.isFinite(source.lastSolveAt)&&source.lastSolveAt>0)clean.lastSolveAt=Math.min(serverTime()+60_000,source.lastSolveAt);
clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?SharedContracts.normalizeSpecialMechanics(clean.specialMechanicsSeen):SharedContracts.normalizeSpecialMechanics(prior.specialMechanicsSeen);
if(Array.isArray(source.specialMechanicsSeen))clean.specialMechanicsSeen=SharedContracts.normalizeSpecialMechanics([...(prior.specialMechanicsSeen||[]),...source.specialMechanicsSeen]);
clean.updatedAt=serverTime();return clean;
}
@ -281,8 +333,20 @@ function publicStateForWorld(incoming,current,player,worldRevision,rowRevision,m
return{state,clearEvent:{id:meta.id,playerId:player.playerId,playerName:player.name,x:meta.x,y:meta.y,level:meta.level,scoreAwarded:state.scoreAwarded,solvedAt:now,revision:worldRevision}};
}
function cloudDeltaSince(record,since){if(!(since>0)||since>=record.revision)return null;const changes=record.changes.filter(change=>Number.isSafeInteger(change?.revision)&&change.revision>since).sort((a,b)=>a.revision-b.revision);if(!changes.length||changes[0].revision!==since+1||changes[changes.length-1].revision!==record.revision)return null;for(let index=1;index<changes.length;index++)if(changes[index].revision!==changes[index-1].revision+1)return null;const metaIds=new Set(),stateIds=new Set(),deleted=new Set();for(const change of changes){for(const id of change.deleted||[]){metaIds.delete(id);stateIds.delete(id);deleted.add(id)}for(const id of change.metaIds||[]){deleted.delete(id);metaIds.add(id)}for(const id of change.stateIds||[]){deleted.delete(id);stateIds.add(id)}}return{metaIds,stateIds,deleted}}
async function publicWorldPage(record,ids,metaIds=null,stateIds=null){const rows=await Promise.all(ids.map(id=>readWorldBoard(record,id))),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=row.state}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
async function readWorldBoardsBounded(record,ids){const rows=[];for(let offset=0;offset<ids.length;offset+=CLOUD_READ_CONCURRENCY)rows.push(...await Promise.all(ids.slice(offset,offset+CLOUD_READ_CONCURRENCY).map(id=>readWorldBoard(record,id))));return rows}
function completedProgressState(state){if(!state||state.solved===true)return state;const visible=JSON.parse(JSON.stringify(state));visible.paths=(visible.paths||[]).filter(path=>Number.isInteger(path?.endGate));visible.specialProgress={crossings:[]};return visible}
function completedProgressFingerprint(state){const visible=completedProgressState(state)||{};return JSON.stringify({paths:visible.paths||[],solved:visible.solved===true,expanded:visible.expanded===true})}
async function publicWorldPage(record,ids,metaIds=null,stateIds=null,viewerPlayerId=''){const rows=await readWorldBoardsBounded(record,ids),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=realtimeHub?.hasClaim(viewerPlayerId,id)?row.state:completedProgressState(row.state)}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
function withWorldQueue(task){const run=worldQueue.then(task,task);worldQueue=run.then(()=>undefined,()=>undefined);return run}
function canRebaseWorldPush(record,baseRevision,metas,states){
if(baseRevision===record.revision)return true;
if(baseRevision<0||baseRevision>record.revision)return false;
const delta=cloudDeltaSince(record,baseRevision);if(!delta)return false;
const touched=new Set([...metas.map(meta=>String(meta?.id||'')),...states.map(row=>String(row?.id||''))]);
if(metas.some(meta=>!Object.prototype.hasOwnProperty.call(record.boardVersions,meta?.id)))return false;
for(const id of touched)if(delta.metaIds.has(id)||delta.stateIds.has(id)||delta.deleted.has(id))return false;
return true;
}
async function authenticateRealtime({playerId,token}){
if(!PLAYER_RE.test(String(playerId||''))||!TOKEN_RE.test(String(token||'')))throw new Error('Unauthorized');
const normalizedId=String(playerId).toLowerCase(),record=await readPlayer(normalizedId);
@ -357,7 +421,7 @@ async function handleCloudStatus(_req,res){
}
async function handleCloudSession(req,res){
const body=await readJsonBody(req),result=await playerService.createSession(body.name);return json(res,result.status,result.body);
const ip=requestIp(req);rateLimit(`session:${ip}`,30,60_000);const body=await readJsonBody(req),result=await withPlayerCapacity(async()=>{await assertPlayerCapacity();const created=await playerService.createSession(body.name);playerRecordCount++;return created});return json(res,result.status,result.body);
}
async function handleCloudProfile(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.updateProfile(auth.playerId,body.name);return json(res,result.status,result.body);
@ -373,53 +437,61 @@ async function handlePurchase(req,res){
}
async function pullCloudWorldService(player,parameters){
const record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(parameters.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(parameters.get('eventsSince'),0))),changed=since!==record.revision,
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince);
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime()}};
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince),clearEventsGap=eventsSince>0&&eventsSince<(record.clearEventsDroppedThrough||0);
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,clearEvents,clearEventsGap,player:publicCloudPlayer(player.record),serverTime:serverTime()}};
const cursor=Math.max(0,Math.floor(finiteNumber(parameters.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(parameters.get('at'),record.revision)));if(cursor&&at!==record.revision)return{status:409,body:{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()}};
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds);
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds,player.playerId);
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,clearEvents:cursor?[]:clearEvents,clearEventsGap:cursor?false:clearEventsGap,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
}
async function pushCloudWorldService(player,body){
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
const playerRecord=await readPlayer(player.playerId),previousPlayerRecord=JSON.parse(JSON.stringify(playerRecord)),record=await ensureWorldIndexes(await readWorld()),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0)));if(baseRevision!==record.revision)return{status:409,body:{error:'Revision conflict',revision:record.revision,serverTime:serverTime()}};
const metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];if(metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);return current};
for(const rawMeta of metas){const clean=validateMeta(rawMeta),row=await loadChangedBoard(clean.id);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
const existingIds=new Set(Object.keys(record.boardVersions)),newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
const playerRecord=await readPlayer(player.playerId),previousPlayerRecord=JSON.parse(JSON.stringify(playerRecord)),record=await ensureWorldIndexes(JSON.parse(JSON.stringify(await readWorld()))),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0))),requestedMutationId=String(body.mutationId||''),mutationId=requestedMutationId||`legacy-${crypto.randomBytes(16).toString('hex')}`,metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];
if(requestedMutationId&&!MUTATION_RE.test(requestedMutationId))badRequest('Invalid mutation id');const priorMutation=requestedMutationId&&(record.recentMutations||[]).find(row=>row?.playerId===player.playerId&&row?.mutationId===mutationId);if(priorMutation){const clearEvents=record.clearEvents.filter(event=>event.revision===priorMutation.revision&&event.playerId===player.playerId);return{status:200,body:{revision:priorMutation.revision,duplicate:true,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,player:publicCloudPlayer(playerRecord),serverTime:serverTime()}}}
if(metas.length>MAX_METAS_PER_PUSH||states.length>MAX_BOARDS_PER_PUSH||metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
const metaIds=metas.map(meta=>String(meta?.id||'')),stateIds=states.map(row=>String(row?.id||''));if(new Set(metaIds).size!==metaIds.length||new Set(stateIds).size!==stateIds.length)badRequest('Duplicate board changes');
if(!canRebaseWorldPush(record,baseRevision,metas,states))return{status:409,body:{error:'Revision conflict',revision:record.revision,serverTime:serverTime()}};
if(metaIds.some(id=>!BOARD_RE.test(id)))badRequest('Invalid board metadata');const existingIds=new Set(Object.keys(record.boardVersions)),rawNewMetaIds=metaIds.filter(id=>!existingIds.has(id));if(rawNewMetaIds.length>MAX_NEW_BOARDS_PER_GRANT)badRequest('Too many generated boards');
let expansionGrant=null;if(record.revision>0&&rawNewMetaIds.length){expansionGrant=record.expansionGrants?.[player.playerId];if(!expansionGrant||expansionGrant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(rawNewMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,expansionGrant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=rawNewMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous')}
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),visibleBoardIds=new Set(metaIds),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);return current};
for(const rawMeta of metas){const row=await loadChangedBoard(rawMeta.id),unchanged=row.meta&&rawMeta&&typeof rawMeta==='object'&&worldMetaFingerprint(row.meta)===worldMetaFingerprint(rawMeta),clean=unchanged?JSON.parse(JSON.stringify(row.meta)):validateMeta(rawMeta);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
const newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
if(record.revision>0&&newMetaIds.length){for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});expansionGrant.maxBoards-=newMetaIds.length;if(expansionGrant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
else for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy);
const clearEvents=[];let solved=Math.max(0,Number(record.global?.solved)||0),earnedScore=Math.max(0,Number(record.global?.earnedScore)||0),starterRow=null;
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true,unfinishedBoard=row.state?.solved!==true,existingBoard=existingIds.has(rawRow.id);if(record.revision>0&&existingBoard&&unfinishedBoard&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true,unfinishedBoard=row.state?.solved!==true,existingBoard=existingIds.has(rawRow.id),previousProgress=completedProgressFingerprint(row.state);if(record.revision>0&&existingBoard&&unfinishedBoard&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423,boardId:rawRow.id});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(previousProgress!==completedProgressFingerprint(row.state))visibleBoardIds.add(rawRow.id);if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
for(const [id,row] of changedBoards){if(!row.meta)badRequest(`Board metadata is missing: ${id}`);if(!row.state)row.state=publicStateForWorld({},null,playerRecord,nextRevision,rowRevision,row.meta).state}
await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision}
record.occupancy=occupancy;record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record)};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
const retiredVersions=[];await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){const previousRevision=record.boardVersions[id];await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision;if(Number.isSafeInteger(previousRevision)&&previousRevision!==nextRevision)retiredVersions.push({id,revision:previousRevision})}
record.occupancy=occupancy;record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record),schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
record.changes.push({revision:nextRevision,metaIds:metas.map(meta=>meta.id),stateIds:states.map(row=>row.id),deleted});if(record.changes.length>CHANGE_HISTORY_LIMIT)record.changes.splice(0,record.changes.length-CHANGE_HISTORY_LIMIT);
record.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT)record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_LIMIT);
record.recentMutations=Array.isArray(record.recentMutations)?record.recentMutations:[];record.recentMutations.push({playerId:player.playerId,mutationId,revision:nextRevision,committedAt:record.updatedAt});if(record.recentMutations.length>1024)record.recentMutations.splice(0,record.recentMutations.length-1024);
record.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT){const removed=record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_LIMIT);record.clearEventsDroppedThrough=Math.max(record.clearEventsDroppedThrough||0,...removed.map(event=>Number(event?.revision)||0))}
await commitWorldMutation(record,{nextPlayer:clearEvents.length?playerRecord:null,previousPlayer:clearEvents.length?previousPlayerRecord:null,changedBoardIds:changedBoards.keys()});
for(const[id,row]of changedBoards)if(row.state?.solved===true)realtimeHub?.releaseBoardClaim(id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
return{status:200,body:{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:publicCloudPlayer(playerRecord),serverTime:record.updatedAt}};
await removeRetiredBoardVersions(retiredVersions);
const realtimePage={global:{...(record.global||{}),cloudProfile:null,cloudRevision:nextRevision},metas:{},states:{}};for(const id of visibleBoardIds){const row=changedBoards.get(id);if(!row)continue;if(metaIds.includes(id)&&row.meta)realtimePage.metas[id]=row.meta;if(row.state)realtimePage.states[id]=completedProgressState(row.state)}
for(const[id,row]of changedBoards)if(row.state?.solved===true)realtimeHub?.releaseBoardClaim(id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);if(visibleBoardIds.size)realtimeHub?.broadcastWorldRevision(nextRevision,visibleBoardIds,realtimePage);
return{status:200,body:{revision:nextRevision,duplicate:false,rebased:baseRevision!==record.revision-1,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,player:publicCloudPlayer(playerRecord),serverTime:record.updatedAt}};
}));
}
async function handleCloudPull(req,res,url){
const player=await authenticatedPlayer(req),result=await pullCloudWorldService(player,url.searchParams);return json(res,result.status,result.body);
}
async function handleCloudPush(req,res){
const player=await authenticatedPlayer(req),body=await readJsonBody(req),result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
const player=await authenticatedPlayer(req),ip=requestIp(req);rateLimit(`push:${player.playerId}`,60,60_000);const body=await readJsonBody(req);if(Array.isArray(body.metas)&&body.metas.length){const cost=body.metas.length;rateLimit(`meta-push:${player.playerId}`,16,60_000,cost);rateLimit(`meta-push-ip:${ip}`,32,60_000,cost);rateLimit('meta-push-global',32,60_000,cost)}const result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
}
async function pollingIdentity(req){
const player=await authenticatedPlayer(req);return{playerId:player.playerId,name:player.record.name};
}
async function handleRealtimeConnect(req,res){
const identity=await pollingIdentity(req),result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
const identity=await pollingIdentity(req);rateLimit(`realtime-connect:${identity.playerId}`,20,60_000);const result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime capacity has been reached'),{status:503});return json(res,200,result);
}
async function handleRealtimeClaim(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.claimBoard(identity,body.boardId,body.presenceId);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
}
async function handleRealtimeSend(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.handlePollingMessage(identity,String(body.presenceId||''),body.message,body.afterSequence);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
const identity=await pollingIdentity(req);rateLimit(`realtime-send:${identity.playerId}`,600,60_000);const body=await readJsonBody(req),result=await realtimeHub?.handlePollingMessage(identity,String(body.presenceId||''),body.message,body.afterSequence);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimePoll(req,res,url){
const identity=await pollingIdentity(req),result=realtimeHub?.pollPollingClient(identity,String(url.searchParams.get('presenceId')||''),url.searchParams.get('after'));if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
const identity=await pollingIdentity(req),wait=Math.max(0,Math.min(25_000,Math.floor(finiteNumber(url.searchParams.get('wait'),20_000)))),result=await realtimeHub?.pollPollingClient(identity,String(url.searchParams.get('presenceId')||''),url.searchParams.get('after'),wait);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimeDisconnect(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),disconnected=realtimeHub?.disconnectPollingClient(identity,String(body.presenceId||''))===true;return json(res,200,{disconnected,serverTime:serverTime()});
@ -445,15 +517,15 @@ async function serveStatic(req,res,url){
let pathname;try{pathname=decodeURIComponent(url.pathname)}catch{return json(res,400,{error:'Invalid path'})}
if(pathname==='/'||pathname.endsWith('/')||pathname.endsWith('/debug-items'))pathname='/index.html';
const candidates=[pathname];for(let offset=pathname.indexOf('/',1);offset>=0;offset=pathname.indexOf('/',offset+1))candidates.push(pathname.slice(offset));
let file=null,stat=null,resolvedPathname=pathname;
for(const candidate of candidates){const target=path.resolve(ROOT,`.${candidate}`);if(!target.startsWith(`${ROOT}${path.sep}`)||path.basename(target).startsWith('.'))continue;try{const targetStat=await fsp.stat(target);if(targetStat.isFile()){file=target;stat=targetStat;resolvedPathname=candidate;break}}catch(error){if(error.code!=='ENOENT')throw error}}
let file=null,stat=null,resolvedPathname=pathname;const publicReal=await fsp.realpath(PUBLIC_ROOT),withinPublic=target=>{const relative=path.relative(publicReal,target);return relative===''||relative!=='..'&&!relative.startsWith(`..${path.sep}`)&&!path.isAbsolute(relative)};
for(const candidate of candidates){const relative=candidate.replace(/^\/+/,''),top=relative.split('/')[0];if(!PUBLIC_STATIC_ROOTS.has(top))continue;const target=path.resolve(PUBLIC_ROOT,relative);if(path.basename(target).startsWith('.'))continue;try{const realTarget=await fsp.realpath(target);if(!withinPublic(realTarget))continue;const targetStat=await fsp.stat(realTarget);if(targetStat.isFile()){file=realTarget;stat=targetStat;resolvedPathname=candidate;break}}catch(error){if(error.code!=='ENOENT')throw error}}
if(!file||!stat)return json(res,404,{error:'Not found'});
if(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'});
const ext=path.extname(file).toLowerCase(),body=resolvedPathname==='/runtime-config.js'?Buffer.from("'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'http-poll'});})(globalThis);\n"):null;
const ext=path.extname(file).toLowerCase(),body=resolvedPathname==='/runtime-config.js'?Buffer.from("'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'auto'});})(globalThis);\n"):null;
res.writeHead(200,{
'content-type':MIME[ext]||'application/octet-stream',
'content-length':body?body.length:stat.size,
'cache-control':ext==='.html'||ext==='.js'||ext==='.css'?'no-store':'public, max-age=86400',
'cache-control':ext==='.html'||resolvedPathname==='/runtime-config.js'?'no-store':ext==='.js'||ext==='.css'?'public, max-age=300, stale-while-revalidate=3600':'public, max-age=86400',
'x-content-type-options':'nosniff',
'cross-origin-resource-policy':'same-origin',
'content-security-policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"
@ -499,7 +571,9 @@ async function listenWithPortFallback(server, {host=HOST, preferredPort=PORT, ex
throw Object.assign(new Error(`No available port was found. Tried: ${ports.join(', ')}`), {code:lastError?.code || 'EADDRINUSE', cause:lastError});
}
function createApplicationServer() {
return http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`),apiOffset=url.pathname.lastIndexOf('/api/');if(apiOffset>=0){url.pathname=url.pathname.slice(apiOffset);await handleApi(req,res,url)}else await serveStatic(req,res,url)}catch(error){console.error(error);if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Internal server error',serverTime:serverTime()});else res.destroy()}});
const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`),apiOffset=url.pathname.lastIndexOf('/api/');if(apiOffset>=0){rateLimit(`api:${requestIp(req)}`,600,60_000);url.pathname=url.pathname.slice(apiOffset);await handleApi(req,res,url)}else await serveStatic(req,res,url)}catch(error){if((error.status||500)>=500)console.error(error);else if(process.env.LINK_FIELD_LOG_CLIENT_ERRORS==='1')console.warn(`${req.method} ${req.url}: ${error.message}`);if(!res.headersSent){if(error.retryAfter)res.setHeader('retry-after',String(error.retryAfter));json(res,error.status||500,{error:error.status?error.message:'Internal server error',...(error.boardId?{boardId:error.boardId}:{}),serverTime:serverTime()})}else res.destroy()}});
server.requestTimeout=35_000;server.headersTimeout=10_000;server.keepAliveTimeout=5_000;server.maxRequestsPerSocket=100;
return server;
}
function displayServerUrl(host, port) {
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
@ -509,36 +583,46 @@ function processIsRunning(pid){
if(!Number.isSafeInteger(pid)||pid<=0)return false;
try{process.kill(pid,0);return true}catch(error){return error?.code==='EPERM'}
}
async function processLooksLikeServer(pid){
if(process.platform==='linux')try{const command=(await fsp.readFile(`/proc/${pid}/cmdline`)).toString('utf8').replace(/\0/g,' '),cwd=path.resolve(await fsp.readlink(`/proc/${pid}/cwd`)),entryMatches=command.includes(path.basename(__filename))||command.includes('service-control.js');return entryMatches&&(command.includes(ROOT)||cwd===ROOT)}catch(error){if(error?.code==='ENOENT')return false}
return null;
}
async function readInstanceLock(){try{const raw=(await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim();if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true,heartbeatAt:0};const value=JSON.parse(raw);return value&&typeof value==='object'?value:null}catch(error){if(error?.code==='ENOENT')return null;return null}}
async function refreshInstanceLock(){
if(!instanceLockToken)return;const current=await readInstanceLock();if(current?.pid!==process.pid||current?.token!==instanceLockToken)return;
await atomicWriteJson(INSTANCE_LOCK_FILE,{pid:process.pid,token:instanceLockToken,root:ROOT,heartbeatAt:serverTime()});
}
async function acquireInstanceLock(){
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
for(let attempt=0;attempt<2;attempt++){
try{
const handle=await fsp.open(INSTANCE_LOCK_FILE,'wx',0o600);
await handle.writeFile(`${process.pid}
`,'utf8');
instanceLockToken=crypto.randomBytes(16).toString('hex');await handle.writeFile(`${JSON.stringify({pid:process.pid,token:instanceLockToken,root:ROOT,heartbeatAt:serverTime()})}\n`,'utf8');
await handle.close();
instanceLockHeartbeatTimer=setInterval(()=>{void refreshInstanceLock().catch(error=>console.warn(`Instance lock heartbeat warning: ${error.message}`))},2000);instanceLockHeartbeatTimer.unref?.();
return process.pid;
}catch(error){
if(error?.code!=='EEXIST')throw error;
let existing=0;try{existing=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim())}catch(_){}
if(processIsRunning(existing))throw Object.assign(new Error(`Another LinkField server is already running (PID ${existing}). Stop it before starting a second shared world.`),{code:'EALREADY'});
const lock=await readInstanceLock(),existing=Number(lock?.pid)||0;let active=false;if(processIsRunning(existing)){const identity=await processLooksLikeServer(existing);active=identity===true||identity==null&&(!lock?.heartbeatAt||serverTime()-lock.heartbeatAt<10_000)}
if(active)throw Object.assign(new Error(`Another LinkField server is already running (PID ${existing}). Stop it before starting a second shared world.`),{code:'EALREADY'});
await fsp.unlink(INSTANCE_LOCK_FILE).catch(unlinkError=>{if(unlinkError?.code!=='ENOENT')throw unlinkError});
}
}
throw new Error('Could not acquire the LinkField single-world lock.');
}
async function releaseInstanceLock(){
try{const owner=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim());if(owner===process.pid)await fsp.unlink(INSTANCE_LOCK_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Instance lock cleanup warning: ${error.message}`)}
clearInterval(instanceLockHeartbeatTimer);instanceLockHeartbeatTimer=null;try{const owner=await readInstanceLock();if(owner?.pid===process.pid&&(!owner.token||owner.token===instanceLockToken))await fsp.unlink(INSTANCE_LOCK_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Instance lock cleanup warning: ${error.message}`)}finally{instanceLockToken=null}
}
async function createAutomaticWorldBackup(){let failure=null;for(let attempt=0;attempt<3;attempt++){try{return await createWorldBackup(DATA_DIR,BACKUP_DIR,{retain:7,appVersion:BuildMeta.APP_VERSION})}catch(error){failure=error;if(attempt<2)await new Promise(resolve=>setTimeout(resolve,250))}}throw failure}
async function main(){
await acquireInstanceLock();
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
await fsp.mkdir(PUBLIC_ROOT,{recursive:true,mode:0o755});
await recoverPendingWorldCommit();
await collectRetiredBoardVersions();
const server=createApplicationServer();
realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo});
let server=null;
try {
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
await fsp.mkdir(PUBLIC_ROOT,{recursive:true,mode:0o755});
await recoverPendingWorldCommit();
server=createApplicationServer();
realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo,maxClients:500,maxClientsPerPlayer:5});
const listening=await listenWithPortFallback(server,{explicitPort:PORT_STRICT});
console.log(`LinkField v${BuildMeta.APP_VERSION} shared world: ${displayServerUrl(HOST,listening.port)}`);
if(listening.usedFallback)console.log(`Default port ${listening.preferredPort} was unavailable; using port ${listening.port}.`);
@ -554,10 +638,14 @@ async function main(){
console.warn('The public /api/ path must be forwarded to this Node.js port by the web server.');
}
console.log(`Shared world data: ${DATA_DIR}`);
return {server,realtimeHub,port:listening.port,host:HOST,dataDir:DATA_DIR,publicRoot:PUBLIC_ROOT,portFile:PUBLIC_BRIDGE_PORT_FILE,apacheBridge};
void withWorldQueue(()=>collectRetiredBoardVersions()).then(count=>{if(count)console.log(`Removed ${count} retired board version${count===1?'':'s'}.`)}).catch(error=>console.warn(`Retired board cleanup warning: ${error.message}`));
const maintenanceInterval=setInterval(()=>{void withWorldQueue(()=>collectRetiredBoardVersions()).catch(error=>console.warn(`Retired board cleanup warning: ${error.message}`))},60*60*1000);maintenanceInterval.unref?.();
let backupTimer=null,backupInterval=null;if(BACKUPS_ENABLED){const backup=()=>createAutomaticWorldBackup().then(result=>console.log(`Shared world backup: ${result.destination}`)).catch(error=>console.warn(`Shared world backup warning: ${error.message}`));backupTimer=setTimeout(backup,5000);backupTimer.unref?.();backupInterval=setInterval(backup,24*60*60*1000);backupInterval.unref?.()}
return {server,realtimeHub,port:listening.port,host:HOST,dataDir:DATA_DIR,backupDir:BACKUP_DIR,backupTimer,backupInterval,maintenanceInterval,publicRoot:PUBLIC_ROOT,portFile:PUBLIC_BRIDGE_PORT_FILE,apacheBridge};
} catch (error) {
realtimeHub?.close();
realtimeHub=null;
if(server?.listening){server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))}
await fsp.unlink(PUBLIC_BRIDGE_PORT_FILE).catch(()=>{});
await releaseInstanceLock();
throw error;
@ -565,6 +653,7 @@ async function main(){
}
async function closeApplication(result){
if(!result)return;
clearTimeout(result.backupTimer);clearInterval(result.backupInterval);clearInterval(result.maintenanceInterval);
try{result.realtimeHub?.close()}catch(error){console.warn(`Realtime shutdown warning: ${error.message}`)}
if(result.server?.listening)await new Promise(resolve=>result.server.close(()=>resolve()));
await fsp.unlink(result.portFile||PUBLIC_BRIDGE_PORT_FILE).catch(error=>{if(error?.code!=='ENOENT')console.warn(`Port file cleanup warning: ${error.message}`)});

View file

@ -11,9 +11,29 @@ function normalizePort(value) {
return port;
}
function renderPublicBoundary(){
return `\n`+
` # The service source may share this directory with Apache. Expose only\n`+
` # the curated browser bundle and deny every other direct file request.\n`+
` RewriteRule ^$ - [L]\n`+
` RewriteRule ^(?:index\\.html|style\\.css|favicon\\.(?:svg|ico)|build-meta\\.js|runtime-config\\.js|shared-contracts\\.js|store-catalog\\.(?:generated\\.js|json)|puzzle-patterns\\.js|puzzle-core\\.js|app-logic\\.js|archive-codec\\.js|field-persistence(?:-worker)?\\.js|puzzle-worker\\.js|app\\.js|api-bridge\\.php)$ - [L]\n`+
` RewriteRule ^(?:assets|client)(?:/|$) - [L]\n`+
` RewriteRule ^ - [F,L]\n`;
}
function renderSensitiveFileFallback(){
return `<FilesMatch "(?i)^(?:\\.|server\\.js$|realtime-server\\.js$|package(?:-lock)?\\.json$|build-config\\.json$|README\\.md$)">\n`+
` Require all denied\n`+
`</FilesMatch>\n`;
}
function renderApacheBridge(portValue) {
const port = normalizePort(portValue);
return `${BEGIN_MARKER}\n` +
`ServerSignature Off\n` +
`<IfModule mod_headers.c>\n` +
` Header always unset X-Powered-By\n` +
`</IfModule>\n` +
`<IfModule mod_rewrite.c>\n` +
` RewriteEngine On\n` +
`\n` +
@ -23,17 +43,36 @@ function renderApacheBridge(portValue) {
` RewriteCond %{HTTP:Upgrade} =websocket [NC]\n` +
` RewriteRule ^api/realtime/?$ ws://127.0.0.1:${port}/api/realtime [P,L]\n` +
` </IfModule>\n` +
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\n` +
` <IfModule mod_proxy_http.c>\n` +
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\n` +
` </IfModule>\n` +
` </IfModule>\n` +
`\n` +
` # Shared hosts often disable mod_proxy. Route ordinary API requests\n` +
` # through the bundled PHP bridge instead.\n` +
` RewriteCond %{REQUEST_FILENAME} !-f\n` +
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n` +
renderPublicBoundary()+
`</IfModule>\n` +
`<Files ".linkfield-port">\n` +
` Require all denied\n` +
`</Files>\n` +
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
function renderApacheBootstrap(){
return `${BEGIN_MARKER}\n`+
`ServerSignature Off\n`+
`<IfModule mod_headers.c>\n`+
` Header always unset X-Powered-By\n`+
`</IfModule>\n`+
`<IfModule mod_rewrite.c>\n`+
` RewriteEngine On\n\n`+
` # Safe bootstrap: use the bounded PHP bridge until the Node server\n`+
` # writes a verified current proxy port after it begins listening.\n`+
` RewriteCond %{REQUEST_FILENAME} !-f\n`+
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n`+
renderPublicBoundary()+
`</IfModule>\n`+
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
@ -59,4 +98,4 @@ async function installApacheBridge({fsp, root, port, enabled = true} = {}) {
return {enabled:true, written:true, file};
}
module.exports = Object.freeze({BEGIN_MARKER, END_MARKER, renderApacheBridge, replaceManagedBlock, installApacheBridge});
module.exports = Object.freeze({BEGIN_MARKER, END_MARKER, renderApacheBridge,renderApacheBootstrap,replaceManagedBlock,installApacheBridge});

View file

@ -2,6 +2,7 @@
function createJsonRepository({fsp,crypto,processId=process.pid}={}){
if(!fsp?.readFile||!fsp?.writeFile||!fsp?.rename||!crypto?.randomBytes)throw new TypeError('Filesystem and crypto adapters are required');
const retryableRenameCodes=new Set(['EACCES','EBUSY','EPERM']),renameRetryDelays=[20,50,100,200,400,800],sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
const read=async(file,{missing=null}={})=>{
try{return JSON.parse(await fsp.readFile(file,'utf8'))}
catch(error){if(error.code==='ENOENT'&&missing!==undefined)return typeof missing==='function'?missing():missing;throw error}
@ -9,7 +10,7 @@ function createJsonRepository({fsp,crypto,processId=process.pid}={}){
const write=async(file,value)=>{
const temporary=`${file}.${processId}.${crypto.randomBytes(6).toString('hex')}.tmp`;
await fsp.writeFile(temporary,JSON.stringify(value),{encoding:'utf8',mode:0o600});
await fsp.rename(temporary,file);
for(let attempt=0;;attempt++)try{await fsp.rename(temporary,file);break}catch(error){if(!retryableRenameCodes.has(error?.code)||attempt>=renameRetryDelays.length){await fsp.unlink?.(temporary).catch(()=>{});throw error}await sleep(renameRetryDelays[attempt])}
};
const remove=async file=>fsp.unlink(file).catch(error=>{if(error.code!=='ENOENT')throw error});
return Object.freeze({read,write,remove});

37
server/public-health.js Normal file
View file

@ -0,0 +1,37 @@
'use strict';
function deploymentBase(value){
const raw=String(value||'').trim();
if(!raw)return null;
const base=new URL(raw.endsWith('/')?raw:`${raw}/`);
if(!['http:','https:'].includes(base.protocol))throw new Error('LinkField public URL must use HTTP or HTTPS');
return base;
}
async function fetchChecked(url,{accept='application/json',timeoutMs=10_000}={}){
const response=await fetch(url,{headers:{accept},signal:AbortSignal.timeout(timeoutMs)});
return response;
}
async function checkPublicDeployment(publicUrl,{appVersion,timeoutMs=10_000}={}){
const base=deploymentBase(publicUrl);if(!base)return null;
const pageUrl=new URL('',base),apiUrl=new URL('api/cloud/status',base),bridgeUrl=new URL('api-bridge.php?path=/api/cloud/status',base);
const page=await fetchChecked(pageUrl,{accept:'text/html',timeoutMs}),pageBody=await page.text();
if(!page.ok||!pageBody.includes('LinkField'))throw new Error(`Public page failed at ${pageUrl.href}: HTTP ${page.status}`);
const disclosed=page.headers.get('server')||'';if(/\//.test(disclosed))throw new Error(`Public server discloses a detailed version: ${disclosed}`);
for(const relative of ['server.js','package.json','scripts/service-control.js','.linkfield-deployment.json']){
const url=new URL(relative,base),response=await fetchChecked(url,{accept:'text/plain',timeoutMs});
if(![403,404].includes(response.status))throw new Error(`Private deployment file is public at ${url.href}: HTTP ${response.status}`);
await response.body?.cancel().catch(()=>{});
}
const results=[];
for(const [kind,url] of [['API',apiUrl],['PHP bridge',bridgeUrl]]){
const response=await fetchChecked(url,{timeoutMs}),body=await response.json().catch(()=>({}));
if(!response.ok||body?.available!==true||body?.appVersion!==appVersion)throw new Error(`Public ${kind} failed at ${url.href}: HTTP ${response.status}`);
if(kind==='PHP bridge'&&response.headers.get('x-linkfield-bridge')!=='php')throw new Error(`Public PHP bridge did not identify itself at ${url.href}`);
results.push({kind,url:url.href,status:response.status});
}
return{base:base.href,page:pageUrl.href,api:results[0].url,bridge:results[1].url};
}
module.exports=Object.freeze({deploymentBase,checkPublicDeployment});

52
server/world-backup.js Normal file
View file

@ -0,0 +1,52 @@
'use strict';
const fs=require('fs');
const fsp=fs.promises;
const path=require('path');
const crypto=require('crypto');
const PLAYER_FILE=/^[a-f0-9]{16,64}\.json$/i;
const BOARD_FILE=/^B(?:0|[1-9][0-9]*)\.[0-9]+\.json$/;
async function hashFile(file){const hash=crypto.createHash('sha256'),stream=fs.createReadStream(file);for await(const chunk of stream)hash.update(chunk);return hash.digest('hex')}
async function copyVerified(source,destination,{link=false}={}){await fsp.mkdir(path.dirname(destination),{recursive:true,mode:0o700});if(link)try{await fsp.link(source,destination);return await hashFile(destination)}catch(error){if(!['EXDEV','EPERM','EACCES','EEXIST'].includes(error?.code))throw error}await fsp.copyFile(source,destination);await fsp.chmod(destination,0o600).catch(()=>{});return hashFile(destination)}
function backupName(date=new Date()){return date.toISOString().replace(/[:.]/g,'-')}
function safeBackupName(value){const name=String(value||'');if(!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name))throw new Error('Invalid LinkField backup name');return name}
async function createWorldBackup(dataDirValue,backupRootValue,{retain=7,appVersion='unknown'}={}){
const dataDir=path.resolve(dataDirValue),backupRoot=path.resolve(backupRootValue),name=backupName(),stage=path.join(backupRoot,`.stage-${name}-${process.pid}`),destination=path.join(backupRoot,name);
await fsp.mkdir(backupRoot,{recursive:true,mode:0o700});await fsp.rm(stage,{recursive:true,force:true});await fsp.mkdir(path.join(stage,'data','shared-world.boards'),{recursive:true,mode:0o700});
try{
const worldFile=path.join(dataDir,'shared-world.json'),world=JSON.parse(await fsp.readFile(worldFile,'utf8')),files=['shared-world.json'];
if(!world||!Number.isSafeInteger(world.revision)||!world.boardVersions||typeof world.boardVersions!=='object')throw new Error('Shared world is not valid enough to back up');
for(const[id,revision]of Object.entries(world.boardVersions)){const name=`${id}.${revision}.json`;if(!BOARD_FILE.test(name))throw new Error(`Invalid board reference in shared world: ${name}`);files.push(path.join('shared-world.boards',name))}
const rootNames=await fsp.readdir(dataDir);for(const file of rootNames)if(PLAYER_FILE.test(file))files.push(file);
const hashes={};for(const relative of files){const source=path.join(dataDir,relative),target=path.join(stage,'data',relative);hashes[relative.replace(/\\/g,'/')]=await copyVerified(source,target,{link:relative.startsWith(`shared-world.boards${path.sep}`)})}
const manifest={app:'LinkField',appVersion,createdAt:new Date().toISOString(),worldRevision:world.revision,fileCount:files.length,hashes};
await fsp.writeFile(path.join(stage,'manifest.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o600});
await fsp.rename(stage,destination);
const names=(await fsp.readdir(backupRoot,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();
for(const retired of names.slice(Math.max(1,retain)))await fsp.rm(path.join(backupRoot,retired),{recursive:true,force:true});
return{destination,name,manifest};
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
}
async function verifyWorldBackup(backupRootValue,nameValue){
const backupRoot=path.resolve(backupRootValue),name=safeBackupName(nameValue),directory=path.join(backupRoot,name),manifest=JSON.parse(await fsp.readFile(path.join(directory,'manifest.json'),'utf8'));
if(manifest?.app!=='LinkField'||!manifest.hashes||typeof manifest.hashes!=='object')throw new Error('Invalid LinkField backup manifest');
for(const[relative,expected]of Object.entries(manifest.hashes)){if(relative.includes('..')||path.isAbsolute(relative))throw new Error('Unsafe backup path');const actual=await hashFile(path.join(directory,'data',relative));if(actual!==expected)throw new Error(`Backup checksum mismatch: ${relative}`)}
return{directory,manifest,name};
}
async function restoreWorldBackup(dataDirValue,backupRootValue,nameValue){
const dataDir=path.resolve(dataDirValue),verified=await verifyWorldBackup(backupRootValue,nameValue),parent=path.dirname(dataDir),stamp=Date.now(),stage=path.join(parent,`.${path.basename(dataDir)}.restore-${stamp}`),previous=path.join(parent,`${path.basename(dataDir)}.pre-restore-${stamp}`);
await fsp.rm(stage,{recursive:true,force:true});await copyDirectory(path.join(verified.directory,'data'),stage);
if(fs.existsSync(dataDir))await fsp.rename(dataDir,previous);
try{await fsp.rename(stage,dataDir)}catch(error){if(fs.existsSync(previous)&&!fs.existsSync(dataDir))await fsp.rename(previous,dataDir);throw error}
return{restored:verified.name,previous};
}
async function copyDirectory(source,destination){const stat=await fsp.lstat(source);if(stat.isSymbolicLink())throw new Error('Backup contains a symbolic link');if(stat.isDirectory()){await fsp.mkdir(destination,{recursive:true,mode:0o700});for(const entry of await fsp.readdir(source))await copyDirectory(path.join(source,entry),path.join(destination,entry));return}if(stat.isFile())await copyVerified(source,destination)}
module.exports=Object.freeze({backupName,createWorldBackup,verifyWorldBackup,restoreWorldBackup});

View file

@ -25,15 +25,15 @@ const {createCursorModel}=require('../client/ui/cursor');
let unauthorized=false;try{auth.parse({headers:{}})}catch(error){unauthorized=error.status===401}
assert(unauthorized,'Authentication middleware did not reject a missing bearer token');
const files=new Map(),fsp={
let renameAttempts=0;const files=new Map(),fsp={
async readFile(file){const value=files.get(file);if(value==null)throw Object.assign(new Error('missing'),{code:'ENOENT'});return value},
async writeFile(file,value){files.set(file,value)},
async rename(from,to){files.set(to,files.get(from));files.delete(from)},
async rename(from,to){if(++renameAttempts<3)throw Object.assign(new Error('busy'),{code:'EPERM'});files.set(to,files.get(from));files.delete(from)},
async unlink(file){if(!files.delete(file))throw Object.assign(new Error('missing'),{code:'ENOENT'})}
};
const repository=createJsonRepository({fsp,crypto:{randomBytes:()=>Buffer.from('abcdef','hex')},processId:1});
await repository.write('world.json',{revision:3});
assert((await repository.read('world.json')).revision===3,'JSON repository did not publish an atomic record');
assert((await repository.read('world.json')).revision===3&&renameAttempts===3,'JSON repository did not retry and publish a transiently blocked atomic record');
await repository.remove('world.json');assert(await repository.read('world.json',{missing:null})===null,'JSON repository missing-value behavior is incorrect');
const cursor=createCursorModel([{cursorStyle:'smile',cursorEmoji:'🙂'},{cursorStyle:'flag',flagAsset:'flag.svg'}]);

View file

@ -87,23 +87,10 @@ async function endpoint(){
}
async function startStaticServer(){
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
const server=http.createServer((request,response)=>{
const pathname=new URL(request.url,'http://localhost').pathname;
const relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1));
const file=path.resolve(root,relative);
if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}
fs.readFile(file,(error,body)=>{
if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}
response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});
response.end(body);
});
});
await new Promise((resolve,reject)=>{
server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()});
});
serverPort=server.address().port;
return server;
const publicDir=path.join(temporaryRoot,'public'),dataRoot=path.join(temporaryRoot,'server-data');
process.env.HOST='127.0.0.1';process.env.PORT='0';process.env.LINK_FIELD_TEST_DATA_ROOT=dataRoot;process.env.LINK_FIELD_PUBLIC_DIR=publicDir;process.env.LINK_FIELD_APACHE_BRIDGE='0';
const service=require('../scripts/service-control');await service.deployPublicFiles(publicDir);
const application=require('../server'),result=await application.main(),server=result.server;serverPort=result.port;server._linkfieldClose=()=>application.closeApplication(result);return server;
}
async function ready(client){
@ -686,7 +673,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
if(startupOnly){
await sleep(12000);
const state=await client.evaluate("({ready:document.body?.dataset?.ready||null,version:document.querySelector('.brand small')?.textContent||null,boards:typeof data!=='undefined'?Object.keys(data.metas).length:null,origin:typeof data!=='undefined'&&Boolean(data.metas?.B0?.puzzle),worldGeneration:typeof data!=='undefined'?data.worldGeneration:null,turnFont:getComputedStyle(document.querySelector('.board-svg text')||document.body).fontFamily,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,300)||''})");
assert(state.ready==='true'&&state.version==='v47.87'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
assert(state.ready==='true'&&state.version==='v48.0'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
assert(/DotGothic16|Press Start 2P|MS Gothic|monospace/i.test(state.turnFont),'Dot-styled game font is not active in the browser');
console.log(`Real-browser startup passed: ${JSON.stringify(state)}`);return;
}
@ -724,7 +711,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
}finally{
client?.close();
stopBrowserTree(edge);
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));
if(server._linkfieldClose)await server._linkfieldClose();else{server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))}
for(let attempt=0;attempt<8;attempt++){
try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}
catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(150)}

View file

@ -118,7 +118,7 @@ assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.inclu
// 15. Completion is shown only after durable shared-world confirmation.
const solveSource=functionSource('checkSolvedAndExpand');
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending()')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending(b.id)')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
assert(solveSource.includes('preparation=prepareExpansionCandidate(b.meta)')&&solveSource.includes('expandMeta(durableMeta,prepared)'),'Expansion generation does not start with the clear display or does not install against durable metadata');
assert(solveSource.includes('playGemCollectionAnimation(immediateBoard,award)')&&functionSource('playGemCollectionAnimation').includes('gemCollectionSources')&&functionSource('playGemCollectionAnimation').includes('scoreCountEl'),'Clear rewards do not travel from the board to the gem wallet');
assert(functionSource('skipCompletionVisuals').includes('finishCompletionVisual')&&functionSource('finishCompletionVisual').includes('visual.resolve'),'Completion visual is not independently skippable');

View file

@ -0,0 +1,27 @@
'use strict';
const assert=require('assert/strict');
const fs=require('fs');
const fsp=fs.promises;
const os=require('os');
const path=require('path');
const {spawn,spawnSync}=require('child_process');
const service=require('../scripts/service-control');
const php=process.env.LINK_FIELD_PHP_PATH||'php',probe=spawnSync(php,['-v'],{encoding:'utf8'});
if(probe.error||probe.status!==0)throw new Error('PHP CLI is required for the bridge integration test');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
(async()=>{
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-php-bridge-')),publicDir=path.join(root,'public'),dataRoot=path.join(root,'data'),nodePort=40000+Math.floor(Math.random()*5000),phpPort=nodePort+5000;
await service.deployPublicFiles(publicDir);
const node=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{env:{...process.env,HOST:'127.0.0.1',PORT:String(nodePort),LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_APACHE_BRIDGE:'0'},stdio:['ignore','pipe','pipe']});
const phpServer=spawn(php,['-S',`127.0.0.1:${phpPort}`,'-t',publicDir],{stdio:['ignore','pipe','pipe']});let errors='';node.stderr.on('data',chunk=>errors+=chunk);phpServer.stderr.on('data',chunk=>errors+=chunk);
try{
let response;for(let i=0;i<100;i++){try{response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=/api/cloud/status`);if(response.ok)break}catch(_){}await sleep(50)}
assert(response?.ok,errors);assert.equal(response.headers.get('x-linkfield-bridge'),'php');const status=await response.json();assert.equal(status.available,true);
response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=/api/cloud/session`,{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"PHP"}'});assert.equal(response.status,201);assert.match((await response.json()).playerId,/^[a-f0-9]{24}$/);
response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=${encodeURIComponent('/api/cloud/status\r\nInjected: true')}`);assert.equal(response.status,400);
console.log('Real PHP-to-Node bridge integration passed');
}finally{node.kill('SIGTERM');phpServer.kill('SIGTERM');await sleep(300);if(node.exitCode==null)node.kill('SIGKILL');if(phpServer.exitCode==null)phpServer.kill('SIGKILL');await fsp.rm(root,{recursive:true,force:true})}
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -0,0 +1,34 @@
'use strict';
const assert=require('assert/strict');
const http=require('http');
const {checkPublicDeployment}=require('../server/public-health');
(async()=>{
let mode='healthy';
const server=http.createServer((request,response)=>{
if(request.url==='/link-field/'||request.url==='/link-field'){
response.setHeader('content-type','text/html');
if(mode==='disclosure')response.setHeader('server','Apache/2.4.66');
response.end('<title>LinkField</title>');return;
}
if(['/link-field/server.js','/link-field/package.json','/link-field/scripts/service-control.js','/link-field/.linkfield-deployment.json'].includes(request.url)){
if(mode==='source'){response.writeHead(200,{'content-type':'text/plain'});response.end('private source');return}
response.writeHead(403);response.end();return;
}
if(request.url==='/link-field/api/cloud/status'){
response.setHeader('content-type','application/json');response.end(JSON.stringify({available:true,appVersion:'48.0'}));return;
}
if(request.url==='/link-field/api-bridge.php?path=/api/cloud/status'){
response.setHeader('content-type','application/json');response.setHeader('x-linkfield-bridge','php');response.end(JSON.stringify({available:true,appVersion:'48.0'}));return;
}
response.writeHead(404);response.end();
});
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve)});
try{
const base=`http://127.0.0.1:${server.address().port}/link-field/`;
const healthy=await checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000});assert.equal(healthy.base,base);
mode='source';await assert.rejects(checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000}),/Private deployment file is public/);
mode='disclosure';await assert.rejects(checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000}),/discloses a detailed version/);
}finally{await new Promise(resolve=>server.close(resolve))}
console.log('Public deployment health and source-boundary smoke test passed');
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -5,7 +5,7 @@ const {execFileSync}=require('child_process');
const tests=[
'shared-contracts-test.js','interaction-ownership-test.js','frame-drag-scheduler-test.js','architecture-boundaries-test.js','source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','v4785-cosmetics-shop-smoke-test.js','v4786-effects-ux-smoke-test.js','v4787-user-cosmetic-realtime-smoke-test.js','v4788-time-attack-navigation-smoke-test.js','v4784-user-request-smoke-test.js','effects-performance-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','server-backup-test.js','server-hardening-test.js','public-health-smoke-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-smoke-test.js','v4801-shared-sync-recovery-smoke-test.js','windows-host-hardening-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
];
for(const file of tests)execFileSync(process.execPath,[path.join(__dirname,file)],{stdio:'inherit'});
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium');

View file

@ -15,7 +15,7 @@ assert(pushCloudSource.includes('mergeCloudPending(cloudPushPending,pending)')&&
const batchContext={};vm.createContext(batchContext);vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('takeCloudPendingBatch')}\nthis.takeCloudPendingBatch=takeCloudPendingBatch;`,batchContext);
const source={metaIds:new Set(Array.from({length:600},(_,index)=>`B${index}`)),stateIds:new Set(['B0']),deleted:new Set(['B999']),globalChanged:true},
{batch,remainder}=batchContext.takeCloudPendingBatch(source,512);
assert(batch.metaIds.size===512&&remainder.metaIds.size===88&&remainder.stateIds.has('B0')&&remainder.deleted.has('B999')&&batch.globalChanged,'Cloud change batching lost or exceeded work');
assert(batch.metaIds.size===1&&batch.metaIds.has('B0')&&batch.stateIds.has('B0')&&remainder.metaIds.size===599&&batch.deleted.has('B999')&&batch.globalChanged,'Cloud batching did not isolate one complete board mutation');
}
const writes={meta:0,state:0,global:0,outbox:0,recovery:0,mirror:0,snapshot:0,checkpoint:0,revision:0,journalClear:0,signal:0,cloud:0};

View file

@ -0,0 +1,26 @@
'use strict';
const assert=require('assert/strict');
const fs=require('fs');
const fsp=fs.promises;
const os=require('os');
const path=require('path');
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
(async()=>{
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-backup-')),world=path.join(root,'world'),backups=path.join(root,'backups');
try{
await fsp.mkdir(path.join(world,'shared-world.boards'),{recursive:true});
const board={meta:{id:'B0'},state:{solved:false}},record={revision:3,global:{worldGeneration:'test'},boardVersions:{B0:3}};
await fsp.writeFile(path.join(world,'shared-world.json'),JSON.stringify(record));
await fsp.writeFile(path.join(world,'shared-world.boards','B0.3.json'),JSON.stringify(board));
await fsp.writeFile(path.join(world,`${'a'.repeat(24)}.json`),JSON.stringify({playerId:'a'.repeat(24)}));
const created=await createWorldBackup(world,backups,{retain:2,appVersion:'test'});assert.equal(created.manifest.worldRevision,3);assert.equal(created.manifest.fileCount,3);
const verified=await verifyWorldBackup(backups,created.name);assert.equal(verified.manifest.app,'LinkField');
await fsp.writeFile(path.join(world,'shared-world.json'),JSON.stringify({...record,revision:4}));
const restored=await restoreWorldBackup(world,backups,created.name);assert(fs.existsSync(restored.previous));
assert.equal(JSON.parse(await fsp.readFile(path.join(world,'shared-world.json'),'utf8')).revision,3);
assert.deepEqual(JSON.parse(await fsp.readFile(path.join(world,'shared-world.boards','B0.3.json'),'utf8')),board);
console.log('Server backup checksum and recoverable restore test passed');
}finally{await fsp.rm(root,{recursive:true,force:true})}
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -0,0 +1,49 @@
'use strict';
const assert=require('assert/strict');
const fs=require('fs');
const fsp=fs.promises;
const http=require('http');
const os=require('os');
const path=require('path');
const {spawn}=require('child_process');
const {root,starterPuzzle}=require('./helpers/app-source');
const {createRealtimeHub}=require('../realtime-server');
const {checkPublicDeployment}=require('../server/public-health');
const BuildMeta=require('../build-meta');
const service=require('../scripts/service-control');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client',untrustedBulk:{ignored:true}}}
(async()=>{
const unitServer=http.createServer(),hub=createRealtimeHub({server:unitServer,authenticate:async()=>null,getBoardInfo:async()=>null,maxClients:10,maxClientsPerPlayer:5});
try{const identity={playerId:'a'.repeat(24),name:'A'},client=hub.createPollingClient(identity);for(let i=0;i<300;i++)hub.broadcastClearEvents([{id:`B${i}`}]);const envelope=hub.pollPollingClient(identity,client.presenceId,0);assert.equal(envelope.eventGap,true);assert(envelope.messages.length<=256);let live=envelope;for(let i=0;i<=300&&live;i++)live=await hub.handlePollingMessage(identity,client.presenceId,{type:'snapshot-request'},live.sequence);assert.equal(live,null,'Realtime message flood was not disconnected')}finally{hub.close()}
let discloseVersion=false;const healthServer=http.createServer((req,res)=>{res.setHeader('server',discloseVersion?'Apache/2.4.58':'Apache');if(req.url==='/'){res.writeHead(200,{'content-type':'text/html'});return res.end('<title>LinkField</title>')}if(['/server.js','/package.json','/scripts/service-control.js','/.linkfield-deployment.json'].includes(req.url)){res.writeHead(403);return res.end()}const body=JSON.stringify({available:true,appVersion:BuildMeta.APP_VERSION});if(req.url?.startsWith('/api-bridge.php'))res.setHeader('x-linkfield-bridge','php');res.writeHead(200,{'content-type':'application/json'});res.end(body)});await new Promise(resolve=>healthServer.listen(0,'127.0.0.1',resolve));const healthBase=`http://127.0.0.1:${healthServer.address().port}/`;try{const checked=await checkPublicDeployment(healthBase,{appVersion:BuildMeta.APP_VERSION});assert.equal(checked.base,healthBase);discloseVersion=true;await assert.rejects(()=>checkPublicDeployment(healthBase,{appVersion:BuildMeta.APP_VERSION}),/discloses a detailed version/)}finally{await new Promise(resolve=>healthServer.close(resolve))}
const dataRoot=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-hardening-')),publicRoot=path.join(dataRoot,'public'),port=36000+Math.floor(Math.random()*2000),base=`http://127.0.0.1:${port}`;
await service.deployPublicFiles(publicRoot);
let symlinkCreated=false;try{await fsp.symlink(path.join(root,'server.js'),path.join(publicRoot,'assets','private-link.js'),'file');symlinkCreated=true}catch(error){if(!['EPERM','EACCES','ENOTSUP'].includes(error?.code))throw error}
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,HOST:'127.0.0.1',PORT:String(port),LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_PUBLIC_DIR:publicRoot},stdio:['ignore','pipe','pipe']});let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
async function request(url,options={}){const response=await fetch(base+url,options),text=await response.text();let body;try{body=JSON.parse(text)}catch{body={raw:text}}return{response,body}}
async function stop(){child.kill('SIGTERM');await Promise.race([new Promise(resolve=>child.once('exit',resolve)),sleep(3000)]);if(child.exitCode==null)child.kill('SIGKILL')}
try{
for(let i=0;i<100;i++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(30)}
const privateFile=await request('/package.json');assert.equal(privateFile.response.status,404);if(symlinkCreated)assert.equal((await request('/assets/private-link.js')).response.status,404);const publicScript=await fetch(`${base}/app.js`);assert.equal(publicScript.status,200);assert.match(publicScript.headers.get('cache-control')||'',/max-age=300/);
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"Alice"}'})).body,bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"Bob"}'})).body;
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,11,puzzle),b1=boardMeta('B1',1,12,puzzle);
const initialPush={baseRevision:0,mutationId:'alice-init-0001',global:{worldGeneration:'attacker',schema:-1,appVersion:'fake',quarantine:{blob:'x'.repeat(1000)}},metas:[b0,b1],states:[{id:'B0',value:{paths:[],solved:false}},{id:'B1',value:{paths:[],solved:false}}]};let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(initialPush)});assert.equal(result.response.status,200,JSON.stringify(result.body));const duplicate=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(initialPush)});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.duplicate,true);assert.equal(duplicate.body.revision,1);
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,mutationId:'alice-expand-0002',metas:[{id:'B2'}],states:[]})});assert.equal(result.response.status,403,'Expansion authorization must run before expensive metadata validation');
const aliceConnect=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'}),bobConnect=await request('/api/realtime/connect',{method:'POST',headers:auth(bob),body:'{}'});assert.equal(aliceConnect.response.status,200);assert.equal(bobConnect.response.status,200);
for(const[session,connected,id]of[[alice,aliceConnect.body,'B0'],[bob,bobConnect.body,'B1']]){const claim=await request('/api/realtime/claim',{method:'POST',headers:auth(session),body:JSON.stringify({presenceId:connected.presenceId,boardId:id})});assert.equal(claim.body.ok,true)}
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,mutationId:'alice-state-0003',global:{worldGeneration:'poisoned'},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false,unknown:{blob:'x'.repeat(1000)}}}]})});assert.equal(result.response.status,200);
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,mutationId:'bob-state-0001',global:{},metas:[],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.equal(result.response.status,200);assert.equal(result.body.rebased,true);
const pull=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.equal(pull.body.page.global.worldGeneration,BuildMeta.WORLD_GENERATION);assert.equal(pull.body.page.global.appVersion,BuildMeta.APP_VERSION);assert.equal(pull.body.page.global.quarantine,undefined);assert.equal(pull.body.page.states.B0.unknown,undefined);
const shardNames=(await fsp.readdir(path.join(dataRoot,'world','shared-world.boards'))).sort();assert.deepEqual(shardNames,['B0.2.json','B1.3.json']);
const viewport=await request('/api/realtime/send',{method:'POST',headers:auth(bob),body:JSON.stringify({presenceId:bobConnect.body.presenceId,message:{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},afterSequence:bobConnect.body.sequence})}),after=viewport.body.sequence,started=Date.now(),pollPromise=request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobConnect.body.presenceId)}&after=${after}&wait=20000`,{headers:auth(bob)});await sleep(300);await request('/api/realtime/send',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:aliceConnect.body.presenceId,message:{type:'release',boardId:'B0'},afterSequence:aliceConnect.body.sequence})});const poll=await pollPromise;assert(Date.now()-started<3000);assert(poll.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'));
for(let i=0;i<4;i++)assert.equal((await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'})).response.status,200);assert.equal((await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'})).response.status,503);
for(let index=2;index<30;index++)assert.equal((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:`P${index}`})})).response.status,201);assert.equal((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{}'})).response.status,429);
console.log('Server integrity, rebase, retention, long-poll, capacity, and rate-limit hardening passed');
}finally{await stop();await fsp.rm(dataRoot,{recursive:true,force:true})}
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -37,5 +37,6 @@ const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
assert.equal(fs.existsSync(commitFile),false);
const poisoned=JSON.parse(fs.readFileSync(worldFile,'utf8'));poisoned.global.worldGeneration='foreign-generation';fs.writeFileSync(worldFile,JSON.stringify(poisoned));fs.writeFileSync(path.join(boardsDir,'B9.99.json'),'{}');assert.equal(await server.collectRetiredBoardVersions(),0);assert.equal(fs.existsSync(path.join(boardsDir,'B9.99.json')),true,'Generation mismatch cleanup deleted an unreferenced shard');
console.log('Shared-world commit recovery and retired-version collection passed');
})().finally(()=>fs.rmSync(dataRoot,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});

View file

@ -54,7 +54,7 @@ const hostedRuntimeContext={globalThis:null,URL,location:{protocol:'https:',href
assert.equal(hostedRuntimeContext.BendRuntimeConfig.cloudApi,true,'HTTP hosting must enable the shared-world API');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.appBaseUrl,'https://host.example/~333/link-field/','Hosted runtime did not preserve the application mount path');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.apiBridgeUrl,'https://host.example/~333/link-field/api-bridge.php','Hosted runtime did not configure the PHP API bridge');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'http-poll','Static hosting must use HTTP realtime polling');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'auto','Hosted runtime must prefer WebSocket and fall back to bounded HTTP polling');
const endpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api/'};vm.createContext(endpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}
this.cloudEndpointUrl=cloudEndpointUrl;`,endpointContext);
assert.equal(endpointContext.cloudEndpointUrl('/api/cloud/status'),'https://host.example/~333/link-field/api/cloud/status','Cloud API URL lost the mounted application path');

View file

@ -26,7 +26,7 @@ assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending(b.id)')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
assert(!functionSource('noteCloudRow').includes("solved!==true")&&functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished shared paths are still excluded from the durable outbox');
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()')&&functionSource('repairExpansions').includes('canExpandSharedBoard(meta,st)'),'Non-solving clients can race the solver while publishing newly generated boards');
@ -146,7 +146,7 @@ assert(AppLogic.generatedShapeFamilyKey(familyA)===AppLogic.generatedShapeFamily
const balancedFamilies=AppLogic.balancedShapeCandidates([familyA,familyARotated,familyB],12345,{hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle}).slice(0,2).map(AppLogic.generatedShapeFamilyKey);
assert(new Set(balancedFamilies).size===2,'Shape balancing still exhausts one orientation-rich family before another family');
for(let level=1;level<=10;level++){
const range=AppLogic.sectionCountRange(level);assert(range.max===level&&range.min===Math.max(1,level-3),`Level ${level} section range is invalid`);
const range=AppLogic.sectionCountRange(level),expected=level<=3?{min:1,max:1}:{min:Math.max(1,level-3),max:level};assert(range.max===expected.max&&range.min===expected.min,`Level ${level} section range is invalid`);
const candidates=AppLogic.shapeCandidatesForLevel(0x470000+level,level,8,shapeDeps);assert(candidates.length>0,`Level ${level} has no section shapes`);
for(const shape of candidates){assert(shape.length>=range.min&&shape.length<=range.max,`Level ${level} generated ${shape.length} sections outside ${range.min}-${range.max}`);const set=new Set(shape.map(([x,y])=>`${x},${y}`));let reached=new Set([`${shape[0][0]},${shape[0][1]}`]),changed=true;while(changed){changed=false;for(const[x,y]of shape)if(!reached.has(`${x},${y}`)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy])=>reached.has(`${x+dx},${y+dy}`))){reached.add(`${x},${y}`);changed=true}}assert(reached.size===set.size,'Generated section shape is disconnected')}
}

View file

@ -16,7 +16,7 @@ assert(app.includes("timeAttackBtn.classList.toggle('final-countdown',remaining<
assert(html.includes('獲得ジェムの倍率UP')&&html.includes('獲得ジェムに応じて次の通り倍率が上昇します。')&&html.includes('<small>基礎累計</small>'),'The multiplier explanation is missing');
assert(app.includes('return timeAttackMultiplier(run.baseCollected||0)')&&app.includes('reward.preTimeAward')&&app.includes('run.baseCollected'),'The multiplier implementation no longer matches the displayed explanation');
assert(!html.includes('id="timeAttackResultCollected"')&&!html.includes('id="timeAttackResultMultiplier"')&&!html.includes('id="timeAttackResultBonus"'),'Removed result fields remain');
assert(app.includes("'https://host.nishi.boats/~333/link-field/'"),'The result URL is missing');
assert(app.includes('cloudAppBaseUrl'),'The time-attack result does not use the current deployment URL');
assert(css.includes('rgba(194,108,255,.42)')&&css.includes('#timeAttackCountdownOverlay'),'The purple multiplier panel or countdown styling is missing');
console.log('v47.88 time-attack and navigation smoke test passed');

View file

@ -4,13 +4,19 @@ const fs=require('fs');
const fsp=fs.promises;
const os=require('os');
const path=require('path');
const {renderApacheBridge,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
const {renderApacheBridge,renderApacheBootstrap,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
(async()=>{
const rendered=renderApacheBridge(8080);
assert.match(rendered,/RewriteRule \^api\/\(\.\*\)\$ http:\/\/127\.0\.0\.1:8080\/api\/\$1 \[P,L\]/);
assert.match(rendered,/ws:\/\/127\.0\.0\.1:8080\/api\/realtime/);
assert.match(rendered,/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
assert.match(rendered,/RewriteRule \^\(\?:assets\|client\)/);
assert.match(rendered,/RewriteRule \^ - \[F,L\]/);
assert.match(rendered,/server\\\.js\$/);
assert.doesNotMatch(rendered,/RewriteRule \^\(\.\*\)\$ - \[L\]/);
const bootstrap=renderApacheBootstrap();assert.match(bootstrap,/api-bridge\.php/);assert.match(bootstrap,/RewriteRule \^ - \[F,L\]/);assert.doesNotMatch(bootstrap,/127\.0\.0\.1:\d+/);
assert.equal((await fsp.readFile(path.join(__dirname,'..','.htaccess'),'utf8')).replace(/\r\n?/g,'\n'),bootstrap,'Checked-in Apache bootstrap drifted from the safe renderer');
assert.equal((rendered.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
assert.equal((rendered.match(new RegExp(END_MARKER,'g'))||[]).length,1);

View file

@ -11,12 +11,15 @@ const {renderApacheBridge}=require('../server/apache-bridge');
const app=fs.readFileSync(require.resolve('../app.js'),'utf8');
const php=fs.readFileSync(require.resolve('../api-bridge.php'),'utf8');
assert.match(runtime,/api-bridge\.php/);
assert.match(runtime,/realtimeTransport:'http-poll'/);
assert.match(runtime,/realtimeTransport:'auto'/);
assert.match(app,/\/api\/realtime\/connect/);
assert.match(app,/\/api\/realtime\/poll/);
assert.match(app,/x-linkfield-authorization/);
assert.match(app,/wait:bridge\?'0':'20000'/);
assert.match(php,/\.linkfield-port/);
assert.match(php,/X-LinkField-Authorization/i);
assert.doesNotMatch(php,/file_get_contents\('php:\/\/input'\)/);
assert.match(php,/php:\/\/temp\/maxmemory:1048576/);
assert.match(renderApacheBridge(4312),/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
const phpCheck=spawnSync('php',['-l',require.resolve('../api-bridge.php')],{encoding:'utf8'});
if(!phpCheck.error)assert.equal(phpCheck.status,0,phpCheck.stderr||phpCheck.stdout);

View file

@ -4,7 +4,7 @@ const fs=require('fs');
const fsp=fs.promises;
const os=require('os');
const path=require('path');
const {spawnSync}=require('child_process');
const {spawn,spawnSync}=require('child_process');
const BuildMeta=require('../build-meta');
const service=require('../scripts/service-control');
@ -15,21 +15,33 @@ const service=require('../scripts/service-control');
const dataDir=path.join(root,'data');
const logFile=path.join(root,'server.log');
const serviceDir=path.join(root,'service');
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,PORT:'0'};
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_APACHE_BRIDGE:'1',LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,LINK_FIELD_LOG_MAX_BYTES:'4096',LINK_FIELD_LOG_ROTATE_INTERVAL_MS:'100',PORT:'0'};
let orphan=null;
try{
await service.deployPublicFiles(publicDir);
const deployedBootstrap=await fsp.readFile(path.join(publicDir,'.htaccess'),'utf8');assert.match(deployedBootstrap,/api-bridge\.php/);assert.match(deployedBootstrap,/RewriteRule \^ - \[F,L\]/);assert.match(deployedBootstrap,/server\\\.js\$/);assert.doesNotMatch(deployedBootstrap,/127\.0\.0\.1:\d+/);
for(const relative of ['index.html','app.js','runtime-config.js','api-bridge.php','assets','client']){
assert.equal(fs.existsSync(path.join(publicDir,relative)),true,`${relative} was not deployed`);
}
const manifest=JSON.parse(await fsp.readFile(path.join(publicDir,'.linkfield-deployment.json'),'utf8'));
assert.equal(manifest.version,'48.0');
await fsp.writeFile(path.join(publicDir,'retired-managed.js'),'obsolete');await fsp.writeFile(path.join(publicDir,'operator-note.txt'),'preserve');
await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),JSON.stringify({...manifest,entries:[...manifest.entries,'retired-managed.js']}));
await service.deployPublicFiles(publicDir);assert.equal(fs.existsSync(path.join(publicDir,'retired-managed.js')),false);assert.equal(await fsp.readFile(path.join(publicDir,'operator-note.txt'),'utf8'),'preserve');
const worldDir=path.join(env.LINK_FIELD_TEST_DATA_ROOT,'world'),worldLockFile=path.join(worldDir,'server.pid');
orphan=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{cwd:service.ROOT,env,stdio:'ignore'});let orphanReady=false;for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const orphanPort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim()),health=await fetch(`http://127.0.0.1:${orphanPort}/api/cloud/status`);if(health.ok){orphanReady=true;break}}catch(_){}}assert(orphanReady,'Legacy-lock recovery fixture did not start');await fsp.writeFile(worldLockFile,String(orphan.pid));const blockedStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:5000});assert.notEqual(blockedStart.status,0);assert.match(blockedStart.stderr,/npm run recover/i);assert.equal(service.isProcessRunning(orphan.pid),true,'Legacy lock preflight stopped an unverified process');
const recovery=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'recover'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});assert.equal(recovery.status,0,recovery.stderr||recovery.stdout);assert.match(recovery.stdout,/Recovered the LinkField world lock/i);assert.equal(service.isProcessRunning(orphan.pid),false,'Explicit recovery did not stop the legacy numeric lock holder');orphan=null;
orphan=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{cwd:service.ROOT,env,stdio:'ignore'});orphanReady=false;for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const orphanPort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim()),health=await fetch(`http://127.0.0.1:${orphanPort}/api/cloud/status`);if(health.ok){orphanReady=true;break}}catch(_){}}assert(orphanReady,'Orphan-server replacement fixture did not start');assert((await service.legacyLinkFieldPids({worldDir})).includes(orphan.pid),'World-lock discovery did not find the orphan LinkField server');
const start=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
assert.equal(start.status,0,start.stderr||start.stdout);
assert.equal(service.isProcessRunning(orphan.pid),false,'npm start did not stop the untracked LinkField server');orphan=null;
assert.match(start.stdout,/started in the background/i);
assert.match(start.stdout,/command prompt is available again/i);
const pidFile=path.join(serviceDir,'server.pid');
const pid=Number((await fsp.readFile(pidFile,'utf8')).trim());
const pidRecord=JSON.parse(await fsp.readFile(pidFile,'utf8')),pid=pidRecord.pid;
assert.match(pidRecord.nonce,/^[a-f0-9]{32}$/);
assert.equal(service.isProcessRunning(pid),true);
const port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());
assert.ok(Number.isSafeInteger(port)&&port>0);
@ -39,11 +51,20 @@ const service=require('../scripts/service-control');
assert.equal(status.sharedWorld,true);
assert.equal(status.appVersion,'48.0');
assert.equal(fs.existsSync(path.join(publicDir,'.htaccess')),true);
await fsp.appendFile(logFile,'x'.repeat(8192));let rotated=false;for(let attempt=0;attempt<50;attempt++){await new Promise(resolve=>setTimeout(resolve,100));if(fs.existsSync(`${logFile}.1`)){rotated=true;break}}assert(rotated,'Supervisor did not rotate a live oversized log');
await fsp.writeFile(pidFile,JSON.stringify({...pidRecord,nonce:'0'.repeat(32)}));const refusedStop=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});assert.notEqual(refusedStop.status,0);assert.match(refusedStop.stderr,/not a verified LinkField supervisor/i);assert.equal(service.isProcessRunning(pid),true);await fsp.writeFile(pidFile,JSON.stringify(pidRecord));
const identityFile=path.join(serviceDir,'service.identity.json'),firstIdentity=JSON.parse(await fsp.readFile(identityFile,'utf8')),firstChildPid=firstIdentity.childPid;
process.kill(firstChildPid,'SIGKILL');
let restartedChildPid=null;
for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,100));try{const identity=JSON.parse(await fsp.readFile(identityFile,'utf8')),activePort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());if(identity.childPid&&identity.childPid!==firstChildPid){const health=await fetch(`http://127.0.0.1:${activePort}/api/cloud/status`).catch(()=>null);if(health?.ok){restartedChildPid=identity.childPid;break}}}catch(_){}}
assert(restartedChildPid,'Supervisor did not restart a crashed LinkField server');
await fsp.unlink(pidFile);
const secondStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
assert.equal(secondStart.status,0,secondStart.stderr||secondStart.stdout);
assert.match(secondStart.stdout,/Replacing the running LinkField server/i);
const replacementPid=Number((await fsp.readFile(pidFile,'utf8')).trim());
const replacementPid=JSON.parse(await fsp.readFile(pidFile,'utf8')).pid;
assert.notEqual(replacementPid,pid);
assert.equal(service.isProcessRunning(pid),false);
assert.equal(service.isProcessRunning(replacementPid),true);
@ -52,6 +73,7 @@ const service=require('../scripts/service-control');
assert.equal(stop.status,0,stop.stderr||stop.stdout);
assert.equal(service.isProcessRunning(replacementPid),false);
}finally{
if(orphan&&service.isProcessRunning(orphan.pid))try{process.kill(orphan.pid,'SIGKILL')}catch(_){}
spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
await fsp.rm(root,{recursive:true,force:true});
}

View file

@ -18,8 +18,8 @@ assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWo
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes('realtimeSend')&&!functionSource('requestBoardClaim').includes('await waitForRealtimeReady()'),'Board input is not using direct claim with realtime fallback');
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired local cross-tab synchronization remains');
assert(serverSource.includes('INSTANCE_LOCK_FILE')&&serverSource.includes('Another LinkField server is already running'),'Server process lock is missing');
assert(serverSource.includes("const PRODUCTION_DATA_DIR = path.resolve('/link-field/world')"),'Shared data is not fixed to /link-field/world');
assert(!serverSource.includes('LINK_FIELD_WORLD_DIR')&&!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select a second shared-world directory');
assert(serverSource.includes("process.env.LINK_FIELD_WORLD_DIR || '/link-field/world'"),'Shared data does not use the safe production default or explicit world-directory override');
assert(!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select an implicit second shared-world directory');
assert(serviceSource.includes('stopLegacyLinkFieldServers')&&serviceSource.includes('Replacing the running LinkField server')&&serviceSource.includes("fsp.unlink(path.join(publicDir,'.linkfield-port'))"),'Old server processes or stale bridge ports can survive deployment');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));

View file

@ -39,7 +39,9 @@ function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetL
const route=puzzle.solution[0],partial={paths:[{startGate:route.startGate,endGate:null,openGate:null,cells:route.cells.slice(0,3).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');assert.equal(result.body.boardId,'B0','Claim failure did not identify the blocked board');
const bobConnected=await request('/api/realtime/connect',{method:'POST',headers:auth(bob),body:'{}'});assert.equal(bobConnected.response.status,200);const bobPresence=bobConnected.body.presenceId;
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});
assert.equal(connected.response.status,200);
@ -53,10 +55,15 @@ function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetL
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
assert.equal(result.response.status,200);assert.equal(result.body.revision,2);
const pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});
assert.equal(pulled.response.status,200);
assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'Another player did not receive unfinished board progress');
assert.equal(pulled.body.page.states.B0.solved,false);
const bobDraftEvents=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobConnected.body.sequence||0}&wait=0`,{headers:auth(bob)});assert.equal(bobDraftEvents.response.status,200);assert(!bobDraftEvents.body.messages.some(message=>message.type==='world-revision'&&message.revision===2),'Every unfinished drag was announced as shared progress');
let pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.response.status,200);assert.deepEqual(pulled.body.page.states.B0.paths,[],'Another player received an open, single-ended draft line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'The active claimant could not recover their own draft line');
console.log('LinkField v48.0 shared board input and progress smoke test passed');
assert(puzzle.solution.length>1,'Starter puzzle needs two routes for shared-progress coverage');const second=puzzle.solution[1],mixed={paths:[{startGate:route.startGate,endGate:route.endGate,openGate:null,cells:route.cells.map(cell=>[...cell])},{startGate:second.startGate,endGate:null,openGate:null,cells:second.cells.slice(0,Math.max(1,Math.min(3,second.cells.length))).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:1},metas:[],states:[{id:'B0',value:mixed}],deleted:[]})});assert.equal(result.response.status,200);assert.equal(result.body.revision,3);
const bobRealtime=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobDraftEvents.body.sequence||0}&wait=1000`,{headers:auth(bob)});assert.equal(bobRealtime.response.status,200);const progressRevision=bobRealtime.body.messages.find(message=>message.type==='world-revision'&&message.revision===3&&message.boardIds.includes('B0'));assert(progressRevision,'A completed gate-to-gate line did not emit shared progress');assert.equal(progressRevision.page.states.B0.paths.length,1,'Realtime progress leaked an unfinished draft or omitted the completed line');assert.equal(progressRevision.page.states.B0.paths[0].endGate,route.endGate,'Realtime progress did not contain the latest completed gate-to-gate line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.body.page.states.B0.paths.length,1);assert.equal(pulled.body.page.states.B0.paths[0].endGate,route.endGate,'Other player did not receive the completed gate-to-gate line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.equal(pulled.body.page.states.B0.paths.length,2,'The claimant lost their private open draft while sharing a completed line');
console.log('LinkField v48.03 immediate completed-line progress visibility smoke test passed');
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});

View file

@ -15,5 +15,5 @@ assert(functionSource('pullCloudWorld').includes('cloudSyncing=false;setCloudSta
assert(functionSource('disconnectRealtimeForLifecycle').includes('keepalive:true'),'HTTP polling presence is not disconnected when the page closes');
assert(read('realtime-server.js').includes("claim.ownerPresenceId === client.id")&&read('realtime-server.js').includes("releaseBoardClaim(boardId, 'disconnected')"),'Disconnected clients can retain board claims');
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('await stop({quiet:true})')<functionSource('start',service).indexOf('deployPublicFiles()'),'npm start does not replace a stale server before deployment');
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('deployPublicFiles()')<functionSource('start',service).indexOf('await stop({quiet:true})')&&functionSource('deployPublicFiles',service).includes('fsp.rename(stage,publicDir)'),'npm start does not stage and atomically publish before replacing the managed server');
console.log('LinkField v48.0 startup version and retry regression test passed');

View file

@ -9,7 +9,7 @@ const {root,starterPuzzle,read,functionSource,loadBendPuzzle}=require('./helpers
const app=read('app.js'),catalog=require('../store-catalog.json');
const bindBoard=functionSource('bindBoard'),claimRequest=functionSource('requestBoardClaim'),removeClaim=functionSource('removeClaim'),claimPresentation=functionSource('applyClaimPresentationToBoard');
assert(!bindBoard.includes('pointerover'),'Hover still starts board ownership');
assert(bindBoard.includes("const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit')")&&bindBoard.indexOf('!endpointTarget&&!gateTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Ownership is requested before a knob/endpoint operation starts');
assert(bindBoard.includes("boardTarget=e.target.closest?.('.board-input-surface')")&&bindBoard.includes('!endpointTarget&&!gateTarget&&!boardTarget')&&bindBoard.indexOf('!endpointTarget&&!gateTarget&&!boardTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Board cells and existing lines cannot enter the claimed input path');
assert(claimPresentation.includes("own?'プレイ中'"),'Own board badge is not labelled プレイ中');
assert(!claimRequest.includes('toast(')&&!removeClaim.includes('toast('),'Board ownership still emits bottom notifications');
assert(functionSource('applyCloudEnvelope').includes('applyPlayerEconomyEnvelope(result)'),'Clear push response does not update the local gem wallet');
@ -48,7 +48,7 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}]})});
assert.equal(result.response.status,200,JSON.stringify(result.body));assert.equal(result.body.clearEvents.length,1,'Clear was not authoritatively accepted');assert(result.body.player.earnedScore>0,'Clear response did not include earned gems');assert.equal(result.body.player.availableScore,result.body.player.earnedScore);
const reward=result.body.player.earnedScore,clearRevision=result.body.revision;
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');assert(polling.body.messages.some(message=>message.type==='world-revision'&&message.revision===clearRevision&&message.boardIds.includes('B0')),'Clear did not announce its world revision in realtime');
let pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Other player did not receive the clear');
const playerState=await request('/api/player/state',{headers:auth(alice)});assert.equal(playerState.body.player.earnedScore,reward,'Gem wallet did not persist the clear reward');
const stale=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:clearRevision,global:{nextId:1},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});assert.equal(stale.response.status,200,JSON.stringify(stale.body));

View file

@ -0,0 +1,26 @@
'use strict';
const assert=require('assert/strict');
const {functionSource,read}=require('./helpers/app-source');
const bindBoard=functionSource('bindBoard'),takeBatch=functionSource('takeCloudPendingBatch'),pushPending=functionSource('pushCloudPending'),claim=functionSource('requestBoardClaim'),handleRealtime=functionSource('handleRealtimeMessage'),scheduleRemote=functionSource('scheduleRemoteWorldPull');
assert(read('index.html').includes('app.js?v=48.0.3'),'The gameplay hotfix is not cache-busted for already-open v48.0 browsers');
assert(bindBoard.includes("boardTarget=e.target.closest?.('.board-input-surface')")&&bindBoard.includes('!endpointTarget&&!gateTarget&&!boardTarget'),'Existing path cells do not reach the line pickup logic');
assert(claim.includes('allowLocalSolved=false')&&claim.includes('!allowLocalSolved&&metaState(boardId)?.solved'),'A locally solved board cannot be reclaimed after a rejected clear push');
assert(pushPending.includes("error.status===423")&&pushPending.includes("requestBoardClaim(boardId,{allowLocalSolved:true})"),'A claim-rejected cloud save does not reclaim and retry its board');
assert(handleRealtime.includes("message.type==='world-revision'")&&handleRealtime.includes('scheduleRemoteWorldPull'),'Realtime world changes are ignored by the browser');
assert(scheduleRemote.includes('pullCloudWorld()')&&scheduleRemote.includes('scheduleRemoteWorldPull(remoteWorldRevision,500)'),'A realtime pull lost during an interaction is not retried');
assert(functionSource('pullCloudWorld').includes('reopenMissingGateExpansions()')&&functionSource('pullCloudWorld').includes('scheduleExpansionRepair(150)'),'A reconciled generated-board conflict remains falsely marked expanded until reload');
assert(functionSource('scheduleRealtimeHttpPoll').includes('PHP_REALTIME_POLL_INTERVAL')&&functionSource('queueRealtimeCursor').includes('PHP_REALTIME_CURSOR_INTERVAL'),'PHP fallback cursor latency remains on the multi-second cadence');
assert(functionSource('recoverLostBoardPointerCapture').includes('realtimeHeldPointers.has(pointerId)')&&bindBoard.includes('recoverLostBoardPointerCapture(b,e)'),'A transient pointer-capture loss still terminates an actively held drag');
assert(functionSource('preserveActiveDrawingClaim').includes("requestBoardClaim(boardId,{force:true})")&&!functionSource('removeClaim').includes('cancelPointerGestures'),'A transient claim update still forcibly stops an active drag');
assert(functionSource('applyRealtimeWorldDelta').includes('mergeSnapshotIntoData')&&handleRealtime.includes('applyRealtimeWorldDelta(message)'),'Realtime map changes still require an additional cloud pull before rendering');
assert(functionSource('scheduleCloudPush').includes('delay=120'),'Completed progress waits too long before publication');
for(let level=1;level<=3;level++)assert.deepEqual(require('../app-logic').sectionCountRange(level),{min:1,max:1},`Level ${level} can still generate merged sections`);
assert(functionSource('placeChildAtFrontierAttempt').includes("level<=3&&shape.length!==1"),'A low intrinsic difficulty can bypass the single-square generation rule');
const emptyCloudPending=()=>({metaIds:new Set(),stateIds:new Set(),deleted:new Set(),globalChanged:false});
const take=Function('emptyCloudPending',`return (${takeBatch})`)(emptyCloudPending),source={metaIds:new Set(['B1','B2']),stateIds:new Set(['B1','B2']),deleted:new Set(['B9']),globalChanged:true};
const {batch,remainder}=take(source,16,'B2');
assert.deepEqual([...batch.metaIds],['B2']);assert.deepEqual([...batch.stateIds],['B2']);assert.deepEqual([...remainder.metaIds],['B1']);assert.deepEqual([...remainder.stateIds],['B1']);assert.equal(batch.globalChanged,true);
assert(functionSource('checkSolvedAndExpand').includes('pushCloudPending(b.id)'),'Clear confirmation can publish an unrelated older board instead of the solved board');
console.log('LinkField v48.03 drag recovery and low-latency map synchronization smoke test passed');

View file

@ -0,0 +1,33 @@
'use strict';
const assert=require('assert/strict');
const fs=require('fs');
const os=require('os');
const path=require('path');
const {spawnSync}=require('child_process');
if(process.platform!=='win32'){
console.log('Windows host-hardening smoke test skipped on this platform');
process.exit(0);
}
const root=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-host-hardening-'));
try{
const apacheRoot=path.join(root,'Apache24'),apacheConfig=path.join(apacheRoot,'conf','httpd.conf'),phpIni=path.join(root,'php.ini');
fs.mkdirSync(path.dirname(apacheConfig),{recursive:true});
fs.writeFileSync(apacheConfig,'Listen 80\nServerSignature On\n');
fs.writeFileSync(phpIni,'expose_php = On\n');
const script=path.join(__dirname,'..','scripts','harden-windows-host.ps1');
const args=['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',script,'-ApacheConfig',apacheConfig,'-PhpIni',phpIni,'-Apply','-SkipApacheSyntaxCheck'];
const first=spawnSync('powershell.exe',args,{encoding:'utf8'});assert.equal(first.status,0,first.stderr||first.stdout);
const apache=fs.readFileSync(apacheConfig,'utf8'),php=fs.readFileSync(phpIni,'utf8');
assert.match(apache,/BEGIN LINKFIELD HOST HARDENING[\s\S]*ServerTokens Prod[\s\S]*ServerSignature Off/);
assert.match(php,/BEGIN LINKFIELD HOST HARDENING[\s\S]*expose_php = Off/);
assert.equal((apache.match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
assert.equal((php.match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
assert(fs.readdirSync(path.dirname(apacheConfig)).some(name=>name.startsWith('httpd.conf.linkfield-backup-')));
assert(fs.readdirSync(root).some(name=>name.startsWith('php.ini.linkfield-backup-')));
const second=spawnSync('powershell.exe',args,{encoding:'utf8'});assert.equal(second.status,0,second.stderr||second.stdout);
assert.equal((fs.readFileSync(apacheConfig,'utf8').match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
assert.equal((fs.readFileSync(phpIni,'utf8').match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
}finally{fs.rmSync(root,{recursive:true,force:true})}
console.log('Windows Apache/PHP host hardening smoke test passed');