diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b98fd21..318fad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + + AllowOverride All + Require all granted + + 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/ diff --git a/.htaccess b/.htaccess index b6da061..195b7a9 100644 --- a/.htaccess +++ b/.htaccess @@ -1,22 +1,24 @@ # BEGIN LINKFIELD MANAGED PROXY +ServerSignature Off + + Header always unset X-Powered-By + RewriteEngine On - # Prefer a native Apache proxy when the host permits it. - - - RewriteCond %{HTTP:Upgrade} =websocket [NC] - RewriteRule ^api/realtime/?$ ws://127.0.0.1:32956/api/realtime [P,L] - - RewriteRule ^api/(.*)$ http://127.0.0.1:32956/api/$1 [P,L] - - - # 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] - + Require all denied - + # END LINKFIELD MANAGED PROXY diff --git a/README.md b/README.md index b936510..bcb7e66 100644 --- a/README.md +++ b/README.md @@ -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の共有確定ルール diff --git a/api-bridge.php b/api-bridge.php index c7cb38d..7ad3f71 100644 --- a/api-bridge.php +++ b/api-bridge.php @@ -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); diff --git a/app-logic.js b/app-logic.js index aa6cbde..9bb1fc9 100644 --- a/app-logic.js +++ b/app-logic.js @@ -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){ diff --git a/app.js b/app.js index 4816ee8..051b92e 100644 --- a/app.js +++ b/app.js @@ -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]||[])(used0||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>>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&&index0)){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++){ diff --git a/docs/effects-cosmetics-performance-plan.md b/docs/effects-cosmetics-performance-plan.md deleted file mode 100644 index 8a159cf..0000000 --- a/docs/effects-cosmetics-performance-plan.md +++ /dev/null @@ -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.