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.