Compare commits

..

3 commits

Author SHA1 Message Date
9d70afb4cc t
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled
2026-08-01 22:31:04 +09:00
4c4e767ec6 d 2026-08-01 16:06:14 +09:00
c3a6f5ff37 ddd 2026-07-31 12:54:46 +09:00
120 changed files with 6556 additions and 2272 deletions

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,json,css,md}]
indent_style = space
indent_size = 2
max_line_length = 180
[puzzle-patterns.js]
max_line_length = off
[store-catalog.generated.js]
max_line_length = off

73
.github/workflows/ci.yml vendored Normal file
View file

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

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Runtime state belongs outside the repository.
cloud-data/
node_modules/
# Browser-test artifacts and owned temporary profiles.
.tmp-edge-profile/
*.browser-profile/
*-smoke.png
*-probe.png
# Local logs and platform metadata.
*.log
Thumbs.db
.DS_Store
# Runtime-selected local bridge port.
.linkfield-port
.linkfield-server.pid
.linkfield-port
.linkfield-deployment.json
link-field.log

24
.htaccess Normal file
View file

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

205
README.md
View file

@ -0,0 +1,205 @@
# LinkField v48.0
LinkFieldは、サーバー上の**単一共有ワールド専用**です。ローカル盤面、同期コード、複数ワールド切替はありません。
## 起動
ZIPを展開したプロジェクト直下で実行します。
```bash
npm start
```
`npm start` は次を自動実行し、完了後すぐにコマンド入力へ戻ります。
1. 公開ファイルを一時ディレクトリで完成させ、ディレクトリ単位で切り替え
2. 稼働中の管理済みLinkFieldサーバーと旧版サーバーを安全に停止
3. 古いポート情報を削除
4. 自動再起動・ハートビート付きの監視プロセスをバックグラウンド起動
5. ローカルAPIと、設定済みの場合は公開URLの疎通を確認
6. 実際の使用ポートを `.linkfield-port` へ保存
8080番が使用中の場合は空きポートへ自動的に切り替わります。公開側のPHPブリッジは `.linkfield-port` を参照するため、固定ポートは不要です。
正常時の表示例:
```text
LinkField started in the background. The command prompt is available again.
PID: 12345
Local port: 3000
Public directory: /home/333/public_html/link-field
Log: /home/333/.local/share/LinkField/service/server.log
```
## 公開ディレクトリが異なる場合
Apacheが別のディレクトリを公開している場合だけ指定します。
```bash
LINK_FIELD_PUBLIC_DIR=/実際の公開ディレクトリ npm start
```
例:
```bash
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://2012r2.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'
```
正常時は、`available`、`sharedWorld`、`singleWorld` がすべて `true` のJSONが返ります。
```json
{"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
npm run status
ls -l "$HOME/public_html/link-field/api-bridge.php"
```
## 管理コマンド
```bash
npm run status # 状態確認
npm run restart # 再起動
npm stop # 停止
npm run backup # 停止中の手動バックアップ
npm run backups # バックアップ一覧とチェックサム検証
npm run restore -- 2026-08-01T00-00-00-000Z # 停止中に復元
```
ログ確認:
```bash
tail -f "$HOME/.local/share/LinkField/service/server.log"
```
ログは10 MiBで自動ローテーションし、5世代を保持します。監視プロセスは5分間に最大10回までクラッシュしたサーバーを再起動します。
前面起動で直接ログを見る場合:
```bash
npm run start:foreground
```
前面起動中にそのターミナルへ入力できないのは正常です。終了は `Ctrl+C` です。
## データ保存先
共有ワールドの既定保存先は次の1か所です。
```text
/link-field/world
```
起動ユーザーがこのディレクトリへ書き込める必要があります。初回だけ権限を準備する場合は、サーバー管理権限で次を実行します。
```bash
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` で変更できます。
展開フォルダやブラウザごとに別ワールドは作成されません。通常ウィンドウとシークレットウィンドウも同じサーバー正本を取得します。
## 盤面共有
未クリア盤面の線・特殊マス進行もサーバーへ保存されます。盤面を操作する際は専用の共有APIで占有権を即時取得し、利用できない場合だけリアルタイム経路へフォールバックします。占有応答前に指を離しても、ドラッグ軌跡は承認後に適用されます。接続状態は左下のFPS表示直上にある固定サイズの小型インジケーターで確認できます。
## 更新時の起動
`npm start` は、新しい公開ツリーを完成・検証してからアトミックに切り替え、旧LinkFieldサーバーを停止して監視プロセス配下で起動し直します。配置に失敗した場合は旧公開ツリーが維持されます。
```bash
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の共有確定ルール
- 盤面の「プレイ中」は、ゲートまたは線端のつまみを押して操作を開始した時だけ発生します。カーソルを重ねただけでは発生しません。
- 占有に関する画面下部の通知は表示しません。
- 盤面がサーバーでクリア確定すると「プレイ中」は即時解除されます。ページ離脱・接続切断時も解除されます。
- クリア表示とジェム加算は、共有サーバーが解答を検証して受理した後に確定します。送信できなかった場合、端末だけがクリア済みになることはありません。
- 新規盤面を生成できるのは、その盤面をクリアしたプレイヤーだけです。生成後はサーバーへ保存され、他プレイヤーと再アクセス後の両方へ同じ盤面が返ります。
- 初回ラインカラーは、プレミアム商品を除いた通常ラインカラーから選ばれます。

168
api-bridge.php Normal file
View file

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
function fail_json(int $status, string $message): never {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode(['error' => $message, 'serverTime' => (int) round(microtime(true) * 1000)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
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.');
}
$portText = trim((string) @file_get_contents($portFile));
$port = filter_var($portText, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]);
if ($port === false) {
fail_json(503, 'LinkField server port information is invalid. Restart npm start.');
}
$targetPath = isset($_GET['path']) ? (string) $_GET['path'] : '';
if (!preg_match('#^/api/(?:cloud|player|realtime)(?:/[A-Za-z0-9_-]+)*$#D', $targetPath)) {
fail_json(400, 'Invalid LinkField API path.');
}
$query = $_GET;
unset($query['path']);
if ($query) {
$targetPath .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, ['GET', 'POST', 'HEAD'], true)) {
fail_json(405, 'Method not allowed.');
}
[$body, $bodyLength] = request_body(8 * 1024 * 1024);
$authorization = '';
if (function_exists('getallheaders')) {
$headers = getallheaders();
if (is_array($headers)) {
foreach ($headers as $name => $value) {
if (strcasecmp((string) $name, 'Authorization') === 0) $authorization = (string) $value;
if (strcasecmp((string) $name, 'X-LinkField-Authorization') === 0 && $authorization === '') $authorization = (string) $value;
}
}
}
if ($authorization === '') {
$authorization = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_LINKFIELD_AUTHORIZATION'] ?? '');
}
$socket = @stream_socket_client(
'tcp://127.0.0.1:' . $port,
$errorNumber,
$errorMessage,
3,
STREAM_CLIENT_CONNECT
);
if (!is_resource($socket)) {
fail_json(502, 'LinkField server is not reachable on its local port. Restart npm start.');
}
stream_set_timeout($socket, 30);
$requestHeaders = [
$method . ' ' . $targetPath . " HTTP/1.1",
'Host: 127.0.0.1:' . $port,
'Connection: close',
'Accept: application/json',
'Content-Type: application/json',
'Content-Length: ' . $bodyLength,
];
if ($authorization !== '') $requestHeaders[] = 'Authorization: ' . str_replace(["\r", "\n"], '', $authorization);
$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");
}
$meta = stream_get_meta_data($socket);
if ($response === '' || $separator === false || !empty($meta['timed_out'])) {
fclose($socket);
fail_json(504, 'LinkField server did not respond in time.');
}
$headerText = substr($response, 0, $separator);
$responseBody = substr($response, $separator + 4);
$headerLines = explode("\r\n", $headerText);
$statusLine = array_shift($headerLines);
if (!preg_match('#^HTTP/\d(?:\.\d)?\s+(\d{3})#', (string) $statusLine, $statusMatch)) {
fail_json(502, 'Invalid status from the LinkField server.');
}
http_response_code((int) $statusMatch[1]);
foreach ($headerLines as $line) {
$colon = strpos($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', 'retry-after'], true)) {
header($name . ': ' . $value, true);
}
}
header('X-LinkField-Bridge: php', true);
header_remove('X-Powered-By');
if ($method !== 'HEAD') {
echo $responseBody;
while (!feof($socket)) {
$chunk = fread($socket, 64 * 1024);
if ($chunk === false) break;
echo $chunk;
if (function_exists('flush')) flush();
}
}
fclose($socket);

View file

@ -8,6 +8,7 @@
const key2=shared.key2;
function sectionCountRange(level){
const max=Math.max(1,Math.min(10,Math.round(level)||1));
if(max<=3)return{min:1,max:1};
return{min:Math.max(1,max-3),max};
}
function normalizeGeneratedShape(chunks){
@ -90,7 +91,7 @@
function specialSchedule(level,validCellCount,seed,encounteredTypes=[],recentTypes=[]){
const boundedLevel=Math.max(1,Math.min(10,Math.floor(Number(level)||1))),cells=Math.max(1,Math.floor(Number(validCellCount)||1));
if(boundedLevel<5)return{types:[],setCount:0,maxCells:0};
const types=['warp','lock','crossing'],encountered=new Set(encounteredTypes||[]),recent=(recentTypes||[]).filter(type=>types.includes(type)).slice(-3),
const types=['warp','lock','crossing','internalGate'],encountered=new Set(encounteredTypes||[]),recent=(recentTypes||[]).filter(type=>types.includes(type)).slice(-3),
unseen=types.filter(type=>!encountered.has(type));
if(unseen.length){
const introduced=[...unseen].sort((a,b)=>stableHash([seed,'introduction',a])-stableHash([seed,'introduction',b])||a.localeCompare(b))[0];
@ -105,7 +106,7 @@
function interactionBurden(puzzle){
const paths=puzzle?.solution||[],totalPathLength=paths.reduce((sum,path)=>sum+(path.cells?.length||0),0),pickups=paths.length,
gateTravel=(puzzle?.g||[]).reduce((sum,gate,index,gates)=>index?sum+Math.abs(gate[0]-gates[index-1][0])+Math.abs(gate[1]-gates[index-1][1]):sum,0),
specialSwitches=(puzzle?.specialCells?.crossings?.length||0)+(puzzle?.specialCells?.warps?.length||0)+(puzzle?.specialCells?.locks?.length||0),
specialSwitches=(puzzle?.specialCells?.crossings?.length||0)+(puzzle?.specialCells?.warps?.length||0)+(puzzle?.specialCells?.locks?.length||0)+(puzzle?.specialCells?.internalGates?.length||0),
occupancy=new Map();let ambiguousAdjacency=0;
paths.forEach((path,pathIndex)=>(path.cells||[]).forEach(cell=>occupancy.set(key2(cell[0],cell[1]),pathIndex)));
for(const[key,pathIndex]of occupancy){const[row,column]=key.split(',').map(Number);for(const[dr,dc]of[[1,0],[0,1]]){const neighbor=occupancy.get(key2(row+dr,column+dc));if(neighbor!=null&&neighbor!==pathIndex)ambiguousAdjacency++}}
@ -141,9 +142,9 @@
return balancedShapeCandidates(candidates,hash32((seed>>>0)^0x3c6ef372),{hash32,rngFrom,shuffle});
}
function analyzePathTurns(path,gates,warpPairs,includeEnd=true){
function analyzePathTurns(path,gates,warpPairs,includeEnd=true,internalGateIndexes=[]){
if(!path?.cells?.length||includeEnd&&path.endGate==null)return{count:0,cells:[]};
const sideDelta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},same=(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1],warpMap=new Map();
const sideDelta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},same=(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1],warpMap=new Map(),internal=new Set(internalGateIndexes||[]);
for(const pair of Array.isArray(warpPairs)?warpPairs:[]){if(pair?.a&&pair?.b){warpMap.set(pair.a.join(','),pair.b);warpMap.set(pair.b.join(','),pair.a)}}
const isWarp=(a,b)=>same(warpMap.get(a?.join(',')),b),outside=gate=>{const delta=sideDelta[gate?.[2]]||[0,0];return[(gate?.[0]||0)+delta[0],(gate?.[1]||0)+delta[1]]};
const segments=[];let start=0;
@ -151,9 +152,9 @@
segments.push(path.cells.slice(start));
const cells=[];
segments.forEach((segment,segmentIndex)=>{
if(!segment.length)return;const first=segmentIndex===0,last=segmentIndex===segments.length-1,nodes=[];
if(first)nodes.push(outside(gates?.[path.startGate]));nodes.push(...segment);if(last&&includeEnd)nodes.push(outside(gates?.[path.endGate]));
const offset=first?1:0;
if(!segment.length)return;const first=segmentIndex===0,last=segmentIndex===segments.length-1,nodes=[],hasStartOutside=first&&!internal.has(path.startGate),hasEndOutside=last&&includeEnd&&!internal.has(path.endGate);
if(hasStartOutside)nodes.push(outside(gates?.[path.startGate]));nodes.push(...segment);if(hasEndOutside)nodes.push(outside(gates?.[path.endGate]));
const offset=hasStartOutside?1:0;
for(let index=1;index<nodes.length-1;index++){
const previous=nodes[index-1],cell=nodes[index],next=nodes[index+1],before=[cell[0]-previous[0],cell[1]-previous[1]],after=[next[0]-cell[0],next[1]-cell[1]];
if(before[0]!==after[0]||before[1]!==after[1]){const sourceIndex=index-offset;if(sourceIndex>=0&&sourceIndex<segment.length)cells.push(segment[sourceIndex])}

2477
app.js

File diff suppressed because it is too large Load diff

19
archive-codec.js Normal file
View file

@ -0,0 +1,19 @@
'use strict';
(function attachArchiveCodec(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
if(root)root.BendArchiveCodec=api;
})(typeof globalThis!=='undefined'?globalThis:this,()=>{
const encoder=new TextEncoder(),crcTable=new Uint32Array(256);
for(let index=0;index<256;index++){let value=index;for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1;crcTable[index]=value>>>0}
function encodeUtf8(value){return encoder.encode(String(value))}
function encodeRecord(record){return encodeUtf8(`${JSON.stringify(record)}\n`)}
function crc32Update(crc,bytes){let value=crc>>>0;for(const byte of bytes)value=crcTable[(value^byte)&255]^(value>>>8);return value>>>0}
function crc32Hex(crc){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')}
function parseRecordLine(sourceLine){
const line=String(sourceLine).replace(/\r$/,'');let record;
try{record=JSON.parse(line)}catch(_){throw new Error('An archive record is not valid JSON.')}
return{line,record,bytes:encodeUtf8(`${line}\n`)};
}
return Object.freeze({encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine});
});

9
build-config.json Normal file
View file

@ -0,0 +1,9 @@
{
"SAVE_SCHEMA": 31,
"STORAGE_SCHEMA": 30,
"IDB_LAYOUT_VERSION": 8,
"FIELD_STORAGE_FORMAT": 2,
"GAMEPLAY_DATA_VERSION": 3,
"GENERATOR_VERSION": 5,
"WORLD_GENERATION": "linkfield-single-world-20260801"
}

17
build-meta.js Normal file
View file

@ -0,0 +1,17 @@
'use strict';
// Generated from package.json and build-config.json. Do not edit.
(function attachBuildMeta(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
if(root)root.BendBuildMeta=api;
})(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze({
"APP_VERSION": "48.0",
"PACKAGE_VERSION": "48.0.0",
"SAVE_SCHEMA": 31,
"STORAGE_SCHEMA": 30,
"IDB_LAYOUT_VERSION": 8,
"FIELD_STORAGE_FORMAT": 2,
"GAMEPLAY_DATA_VERSION": 3,
"GENERATOR_VERSION": 5,
"WORLD_GENERATION": "linkfield-single-world-20260801"
}));

73
client/input/drag.js Normal file
View file

@ -0,0 +1,73 @@
(function(root,factory){
const api=factory(root.BendFrameScheduler);
if(typeof module==='object'&&module.exports)module.exports=factory(require('./frame-scheduler.js'));
root.BendDragScheduler=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(FrameScheduler){
'use strict';
const STATES=Object.freeze(['idle','armed','running','draining','settling','cancelled']);
function createDragScheduler(options={}){
if(!FrameScheduler?.createFrameScheduler)throw new TypeError('BendFrameScheduler is required');
if(typeof options.onFrame!=='function')throw new TypeError('Drag onFrame callback is required');
const maxSamples=Math.max(2,Number(options.maxSamples)||12);
const trim=typeof options.trim==='function'?options.trim:samples=>samples.splice(0,Math.max(0,samples.length-maxSamples));
let state='idle',pointerId=null,latest=null,logical=[],revision=0,visualRevision=0,logicalRevision=0;
const frame=FrameScheduler.createFrameScheduler({
interval:options.interval,
tolerance:options.tolerance,
watchdogDelay:options.watchdogDelay,
requestFrame:options.requestFrame,
cancelFrame:options.cancelFrame,
setDelay:options.setDelay,
clearDelay:options.clearDelay,
now:options.now,
onWatchdog:options.onWatchdog,
commit:(_signal,timestamp)=>options.onFrame(api,timestamp)
});
const assertState=next=>{if(!STATES.includes(next))throw new TypeError(`Unknown drag state: ${next}`);state=next};
const clearData=()=>{pointerId=null;latest=null;logical.length=0;revision=0;visualRevision=0;logicalRevision=0};
const arm=id=>{
if(id==null)throw new TypeError('Drag pointerId is required');
frame.cancel({resetCadence:true});clearData();pointerId=id;assertState('armed');return api;
};
const push=(sample,{schedule=true}={})=>{
if(!sample||sample.pointerId==null)return false;
if(state==='idle'||state==='cancelled'||state==='settling')arm(sample.pointerId);
if(sample.pointerId!==pointerId||state==='draining')return false;
latest=sample;logical.push(sample);trim(logical,maxSamples);revision++;
if(state==='armed')assertState('running');
if(schedule)frame.push(revision);
return revision;
};
const requestFrame=()=>{if(latest&&state!=='idle'&&state!=='cancelled')frame.push(revision)};
const beginDrain=sample=>{
if(sample)push(sample,{schedule:false});
if(state==='idle'||state==='cancelled')return false;
frame.cancel();assertState('draining');return true;
};
const beginSettling=()=>{if(state==='draining'||state==='running'||state==='armed')assertState('settling');frame.cancel();return state==='settling'};
const settle=()=>{frame.cancel({resetCadence:true});clearData();assertState('idle')};
const cancel=()=>{frame.cancel({resetCadence:true});clearData();assertState('cancelled')};
const takeLogical=max=>{
const count=Math.max(0,Math.min(logical.length,Number(max)||1));
return logical.splice(0,count);
};
const drainLogical=()=>logical.splice(0);
const markVisual=()=>{visualRevision=revision};
const markLogical=()=>{if(!logical.length)logicalRevision=revision};
const inspect=()=>Object.freeze({
state,pointerId,latest,logicalCount:logical.length,revision,visualRevision,logicalRevision,
freshVisual:visualRevision!==revision,freshLogical:logicalRevision!==revision
});
const api=Object.freeze({
arm,push,requestFrame,beginDrain,beginSettling,settle,cancel,takeLogical,drainLogical,
latest:()=>latest,
hasLogical:()=>logical.length>0,
markVisual,markLogical,inspect
});
return api;
}
return Object.freeze({STATES,createDragScheduler});
});

View file

@ -0,0 +1,85 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendFrameScheduler=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_INTERVAL=1000/60;
function createFrameScheduler(options={}){
const requestFrame=options.requestFrame||globalThis.requestAnimationFrame?.bind(globalThis);
const cancelFrame=options.cancelFrame||globalThis.cancelAnimationFrame?.bind(globalThis);
const setDelay=options.setDelay||globalThis.setTimeout?.bind(globalThis);
const clearDelay=options.clearDelay||globalThis.clearTimeout?.bind(globalThis);
const now=options.now||(()=>globalThis.performance?.now?.()||Date.now());
const interval=Math.max(1,Number(options.interval)||DEFAULT_INTERVAL);
const tolerance=Math.max(0,Number(options.tolerance)||0);
const watchdogDelay=Math.max(interval,Number(options.watchdogDelay)||interval+2);
const commit=options.commit;
const validTiming=typeof requestFrame==='function'&&typeof cancelFrame==='function'
&&typeof setDelay==='function'&&typeof clearDelay==='function';
if(!validTiming)throw new TypeError('Frame scheduler timing functions are required');
if(typeof commit!=='function')throw new TypeError('Frame scheduler commit callback is required');
let frame=0,timer=0,lastCommitAt=0,latest=null,revision=0,committedRevision=0,disposed=false;
const clearArmed=()=>{
if(frame)cancelFrame(frame);
if(timer)clearDelay(timer);
frame=0;timer=0;
};
const arm=()=>{
if(disposed||frame||timer||latest==null)return false;
const step=timestamp=>{
if(disposed||latest==null){clearArmed();return false}
const at=Number.isFinite(timestamp)?timestamp:now();
if(lastCommitAt&&at+tolerance<lastCommitAt+interval){
if(frame)cancelFrame(frame);
frame=requestFrame(step);
return false;
}
clearArmed();
const value=latest,currentRevision=revision;
latest=null;lastCommitAt=at;committedRevision=currentRevision;
commit(value,at,currentRevision);
if(latest!=null)arm();
return true;
};
frame=requestFrame(step);
timer=setDelay(()=>{
timer=0;
if(typeof options.onWatchdog==='function')options.onWatchdog();
step(now());
},watchdogDelay);
return true;
};
const push=value=>{
if(disposed)return false;
latest=value;revision++;arm();return revision;
};
const flush=()=>{
if(disposed||latest==null)return false;
clearArmed();
const value=latest,currentRevision=revision,at=now();
latest=null;lastCommitAt=at;committedRevision=currentRevision;
commit(value,at,currentRevision);
if(latest!=null)arm();
return true;
};
const cancel=({resetCadence=false}={})=>{
clearArmed();latest=null;
if(resetCadence)lastCommitAt=0;
};
const dispose=()=>{cancel({resetCadence:true});disposed=true};
const inspect=()=>Object.freeze({
armed:Boolean(frame||timer),
pending:latest!=null,
revision,
committedRevision,
lastCommitAt
});
return Object.freeze({push,flush,cancel,dispose,inspect});
}
return Object.freeze({DEFAULT_INTERVAL,createFrameScheduler});
});

View file

@ -0,0 +1,47 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendGestureCoordinator=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_PRECEDENCE=Object.freeze({
wheel:10,
pan:20,
pinch:30,
reaction:40,
minimap:50,
draw:60,
dialog:70
});
function createGestureCoordinator(precedence=DEFAULT_PRECEDENCE){
const owners=new Map(),listeners=new Set();let revision=0;
const priority=owner=>Number(precedence[owner])||0;
const snapshot=()=>Object.freeze({revision,owners:Object.freeze(Object.fromEntries(owners))});
const notify=(change)=>{revision++;const state=snapshot();for(const listener of listeners)listener(state,change)};
const claim=(pointerId,owner,{replace=false}={})=>{
if(pointerId==null||!owner)return false;const key=String(pointerId),next=String(owner),current=owners.get(key);
if(current===next)return true;
if(current&&!replace&&priority(next)<=priority(current))return false;
owners.set(key,next);notify(Object.freeze({type:current?'transfer':'claim',pointerId:key,owner:next,previous:current||null}));return true;
};
const release=(pointerId,owner=null,reason='complete')=>{
if(pointerId==null)return false;const key=String(pointerId),current=owners.get(key);
if(!current||owner!=null&&current!==String(owner))return false;
owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner:current,reason}));return true;
};
const cancelAll=(reason='cancelled')=>{for(const[key,owner]of[...owners]){owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner,reason}))}};
return Object.freeze({
claim,
release,
cancelAll,
owner:pointerId=>pointerId==null?null:owners.get(String(pointerId))||null,
owns:(pointerId,owner)=>owners.get(String(pointerId))===String(owner),
snapshot,
subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)}
});
}
return Object.freeze({DEFAULT_PRECEDENCE,createGestureCoordinator});
});

View file

@ -0,0 +1,52 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendInteractionState=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_SCOPES=Object.freeze({
any:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap','dialog']),
pointer:Object.freeze(['camera','drawing','claim','reaction','minimap']),
camera:Object.freeze(['camera','wheel']),
persistence:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
world:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
overview:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
worker:Object.freeze(['drawing','claim'])
});
function createInteractionState(scopeDefinitions=DEFAULT_SCOPES){
const owners=new Map(),listeners=new Set(),scopes=new Map(
Object.entries(scopeDefinitions).map(([name,kinds])=>[name,new Set(kinds)])
);
let revision=0;
const snapshot=()=>Object.freeze({
revision,
activeKinds:Object.freeze([...owners.keys()]),
owners:Object.freeze(Object.fromEntries(owners))
});
const notify=()=>{const state=snapshot();for(const listener of listeners)listener(state)};
const set=(kind,owner)=>{
const key=String(kind||'');if(!key)throw new TypeError('Interaction kind is required');
const next=owner==null||owner===false?null:String(owner),previous=owners.get(key)||null;
if(previous===next)return false;
if(next==null)owners.delete(key);else owners.set(key,next);
revision++;notify();return true;
};
const active=(scope='any')=>{
const kinds=scopes.get(scope);if(!kinds)throw new TypeError(`Unknown interaction scope: ${scope}`);
for(const kind of kinds)if(owners.has(kind))return true;
return false;
};
return Object.freeze({
set,
clear:kind=>set(kind,null),
owner:kind=>owners.get(String(kind||''))||null,
active,
snapshot,
subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)}
});
}
return Object.freeze({DEFAULT_SCOPES,createInteractionState});
});

View file

@ -0,0 +1,8 @@
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.001ms!important}
.board-card.revealing,.board-card.completing .path,.completion-burst,.gate-dot.frontier,.tutorial-demo *,.tutorial-demo::after,.num.turn-warning,.number-turn-warning,.pill.time-attack.active>span:first-child,.time-attack-clock.urgent,.completion-flash,.gate-connect-pulse,.number-match-orbit-holes,#viewport::before{animation:none!important}
.completion-burst{opacity:1}
.tutorial-path{stroke-dashoffset:0}
.tutorial-shop-marker,.tutorial-number-pulse{transform:none}
.tutorial-shop-score{opacity:1}
}

6
client/styles/base.css Normal file
View file

@ -0,0 +1,6 @@
*{box-sizing:border-box;user-select:none;-webkit-user-select:none}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:var(--dot-font);font-variant-ligatures:none;-webkit-font-smoothing:none;text-rendering:geometricPrecision}
body,button,input,select,textarea{font-family:var(--dot-font)}
button,input,select,textarea{font-size:inherit}
button{font:inherit;color:inherit;letter-spacing:.04em}
button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-offset:3px}

17
client/styles/tokens.css Normal file
View file

@ -0,0 +1,17 @@
@font-face{font-family:"DotGothic16Local";src:url("../../assets/fonts/DotGothic16-Regular.ttf") format("truetype");font-style:normal;font-weight:400;font-display:swap}
:root{
--bg:#121416;
--panel:#1c1f22;
--ink:#f3f4f5;
--muted:#8b949b;
--grid:rgba(198,210,218,.28);
--accent:#d9f06f;
--bad:#ff6e7a;
--dot-font:"DotGothic16Local","DotGothic16","MS Gothic","MS ゴシック",ui-monospace,monospace;
--emoji-font:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif;
--bar-height:54px;
--safe-top:env(safe-area-inset-top,0px);
--safe-right:env(safe-area-inset-right,0px);
--safe-bottom:env(safe-area-inset-bottom,0px);
--safe-left:env(safe-area-inset-left,0px)
}

17
client/ui/cursor.css Normal file
View file

@ -0,0 +1,17 @@
body[data-cursor-mode="dom"],
body[data-cursor-mode="dom"] *{cursor:none!important}
.emoji-glyph,#customEmojiCursor{font-family:var(--emoji-font)!important;font-variant-emoji:emoji}
.item-flag-image{display:block;width:34px;height:26px;object-fit:contain;pointer-events:none}
#customEmojiCursor{position:fixed;z-index:300;left:0;top:0;display:none;width:32px;height:32px;place-items:center;overflow:visible;border:0;border-radius:0;background:transparent;font-size:26px;line-height:1;opacity:1;pointer-events:none;will-change:transform}
#customEmojiCursor.visible{display:grid}
#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden;border:2px solid #101214;border-radius:50%;background:#f4d45f;font-size:26px;opacity:1;box-shadow:0 2px 7px rgba(0,0,0,.5)}
#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;margin:0;transform:translate(-50%,-50%);border-radius:0;object-fit:cover;object-position:center;clip-path:none}
body.is-drawing #viewport,
body.is-drawing #viewport *{cursor:none!important}
body.is-drawing #customEmojiCursor{display:none!important}
#pickupHandleOverlay{position:fixed;z-index:299;left:0;top:0;display:none;width:15px;height:15px;border:2px solid #111820;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 0 0 2px rgba(255,255,255,.82),0 2px 7px rgba(0,0,0,.48);pointer-events:none;will-change:transform;contain:strict}
#pickupHandleOverlay.visible{display:block}
#pickupHandleOverlay.custom-cursor{width:32px;height:32px;place-items:center;border:0;border-radius:0;background:transparent;box-shadow:none;font:26px/1 var(--emoji-font);overflow:visible}
#pickupHandleOverlay.custom-cursor.visible{display:grid}
#pickupHandleOverlay.custom-cursor.flag-cursor{overflow:hidden;border:2px solid #101214;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 2px 7px rgba(0,0,0,.5)}
#pickupHandleOverlay.custom-cursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;transform:translate(-50%,-50%);object-fit:cover;object-position:center;pointer-events:none}

30
client/ui/cursor.js Normal file
View file

@ -0,0 +1,30 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendCursorModel=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
function createCursorModel(items=[]){
const byStyle=new Map();
for(const item of items)if(item?.cursorStyle)byStyle.set(item.cursorStyle,Object.freeze({...item}));
const item=style=>byStyle.get(String(style||''))||null;
const presentation=style=>{
const selected=item(style);
if(!selected)return Object.freeze({style:'default',mode:'default',asset:null,glyph:'',flag:false,hotspot:Object.freeze([0,0]),pickup:Object.freeze({kind:'default'})});
const flag=Boolean(selected.flagAsset),asset=selected.flagAsset||null,glyph=flag?'':selected.cursorEmoji||'';
return Object.freeze({
style:selected.cursorStyle,
mode:'dom',
asset,
glyph,
flag,
hotspot:Object.freeze([.5,.5]),
pickup:Object.freeze({kind:flag?'flag':'glyph',asset,glyph})
});
};
return Object.freeze({item,presentation,styles:()=>Object.freeze([...byStyle.keys()])});
}
return Object.freeze({createCursorModel});
});

View file

@ -53,12 +53,14 @@ Long connected lines become thicker and award more points. Higher-level puzzles
## Shops and items
A solved line may reveal a shop. Each shop offers a deterministic selection of cursor designs and one utility item.
クリアした盤面からショップが現れることがあります。各ショップには、上段にカーソル6点、下段にカーソル以外のアイテム6点が盤面シードに基づいて陳列されます。
- **Score Lens:** Shows a projected reward for an unsolved puzzle. Its base price is 200,000 gems.
- **ラインカラー:** 新しく引く線と利用可能なゲートの色を変更します。各プレイヤーには初期色が1色自動で付与されます。ショップ限定のオーロラは、選定された色へ2秒ごとに切り替わります。
- **リアクション:** 未クリア盤面の外側で送るリアクション演出を変更します。
- **スコアレンズ:** 未クリア盤面の予想報酬を表示します。基本価格は200,000ジェムです。
- **Emoji or flag cursor:** Changes the pointer appearance.
Difficulty-adjustment field items are not part of the current game. Item prices and purchases are validated by the shared-world server when online.
Colors other than the granted starter color, including Aurora, and all enhanced emoji effects must be obtained from shops. Difficulty-adjustment field items are not part of the current game. Item prices and purchases are validated by the shared-world server when online.
## Time Attack
@ -71,14 +73,14 @@ When the game is opened through `server.js`, every player explores the same gene
- Cleared boards and newly generated boards are shared.
- The first accepted solver's name is shown on the cleared board.
- Recent clears appear above the minimap with coordinates and level.
- Unfinished lines and camera state remain local. Player score, purchases, name, and equipped cursor belong to the individual player rather than the shared world.
- Unfinished lines and camera state remain local. Player score, purchases, name, starter color, and equipped cosmetics belong to the individual player rather than the shared world.
Progress is saved automatically. Most synchronization runs in the background, but a completed solution is verified by the server before shared expansion is finalized. If expansion keeps trying for at least eight seconds and still leaves an unresolved new-board frontier, the solver receives a one-time level-6-equivalent bonus. Use the gear button to change the player name and presentation settings; use the shared-status button to restore an existing synchronization code.
## Shared-world play
- Other nearby players appear as named cursors. Their positions are shown as small dots on the minimap.
- Other nearby players appear as named cursors. Their positions are shown as small dots on the minimap. Emoji reactions and their purchased visual styles are also shared with nearby players.
- Grabbing or operating an unfinished board requests exclusive control of that board.
- A board occupied by another player shows their name and cannot be played until released.
- Control expires after five minutes without board operation. A disconnected player follows the same timeout.
@ -88,4 +90,4 @@ Progress is saved automatically. Most synchronization runs in the background, bu
## Settings
Open the gear button to change the player name, enable lightweight rendering, or turn sound effects on and off. Lightweight rendering disables selected background, glow, and particle effects without changing puzzle rules.
Open the gear button to change the player name, enable lightweight rendering, or turn sound effects on and off. Lightweight rendering simplifies selected ambient background and interface decoration without changing puzzle rules. It does not replace purchased emoji reaction styles or automatically drop overlapping reactions.

View file

@ -1,676 +0,0 @@
# Bend Field: Efficient Field Save and Load Design
## Purpose
This document proposes a scalable persistence system for a complete Bend Field
world. It covers two related but separate problems:
1. Loading a large local field quickly enough that the player can begin using it
before every puzzle has been read.
2. Exporting and restoring the complete field without creating several
whole-field copies in memory.
The recommended design preserves the existing incremental autosave behavior,
introduces an index-first local repository, and adds a streamed portable archive.
## Current system assessment
IndexedDB is already the durable local source of truth. Normal gameplay saves are
incremental: only dirty board metadata, dirty board states, global data,
tombstones, recovery information, and cloud-outbox changes are committed. This
part should be retained.
The main scaling problems are elsewhere:
- Startup calls `getAll()` for all metadata and state rows, constructs a complete
snapshot, and normalizes every puzzle before gameplay begins.
- `hydrateMeta()` does not currently load anything from storage. It only checks
that an already loaded puzzle exists.
- The compact localStorage mirror is useful for small-field recovery but is
intentionally limited to about 4.5 MiB and cannot represent a very large
field.
- JSON export constructs a complete snapshot, pretty-printed JSON string, and
Blob in memory.
- The current export payload can contain cloud credentials, cloud transport
state, session identity, and recovery data. These are local persistence
details and must not be portable.
- JSON import reads and parses the complete file in memory, then clears and
repopulates the active stores.
- The current metadata and state stores are keyed only by board ID. A second
complete field cannot be staged beside the active field, so an atomic pointer
switch is not possible with the present keys.
- The original cloud implementation used a complete snapshot for pulls and one
whole-world JSON file on the server.
The local repository and portable backup were implemented first. The follow-up
cloud implementation now batches pushes, pages pulls, returns recent revision
deltas, and stores board revisions in separate crash-safe files.
## Design goals
- Preserve the existing low-cost dirty-row autosave path.
- Make the origin board or current viewport usable before a full field scan
completes.
- Avoid loading every puzzle definition and path collection during startup.
- Keep archive memory use independent of total field size.
- Make restore strict, cancelable, and all-or-nothing.
- Never place cloud credentials, recovery journals, or session identity in a
portable backup.
- Make reset, import, migration, and rollback use the same safe epoch-switching
primitive.
- Keep old JSON saves importable through explicit compatibility rules.
- Preserve multi-tab protection and reject stale writers after a field switch.
## Version boundaries
The following versions serve different purposes and must not share a counter:
- **Archive version** describes the portable `.bfsave` wire format.
- **Save schema** describes gameplay data and its migrations.
- **IndexedDB layout version** describes object stores and indexes.
- **World generation** identifies compatible generated-world rules.
- **Application version** identifies the build that produced an archive.
The proposed portable archive is version 2. The proposed IndexedDB layout is
version 6. Upgrading either one must not implicitly change the other.
The existing physical database name should be reused for the version-6
migration. Future database names should not be derived from the save-schema
number.
## Local repository design
### Object stores
The version-6 upgrade creates the following stores while retaining the current
version-5 stores for migration:
- `control`, keyed by a singleton key.
- `worlds`, keyed by `epoch`.
- `boardIndex`, `boardPuzzles`, `boardStates`, and `tombstonesV2`, keyed by
`[epoch, id]`.
- `recoveryV2` and `outboxV2`, keyed by `[epoch, key]`.
Every epoch-scoped store has an `epoch` index so an abandoned field can be
deleted in bounded batches.
Conceptual records:
```ts
interface WorldControl {
key: "active";
activeFormat: 1 | 2;
activeEpoch: string;
previousEpoch?: string;
activationId: string;
activationVerified: boolean;
switchedAt: number;
}
interface WorldRecord {
epoch: string;
status: "staging" | "ready" | "active" | "rollback" | "garbage";
schema: number;
worldGeneration: string;
global: PortableAndLocalGlobalState;
boardCount: number;
solvedCount: number;
score: number;
bounds: { minX: number; minY: number; maxX: number; maxY: number };
approximateBytes: number;
createdAt: number;
activatedAt?: number;
source?: {
kind: "migration" | "import" | "reset";
archiveCrc32?: string;
};
progress?: {
phase: string;
lastKey?: IDBValidKey;
rows: number;
};
}
interface BoardIndexRecord {
epoch: string;
id: string;
x: number;
y: number;
chunks: [number, number][];
level: number;
targetLevel: number;
seed: number;
axis: string;
entrySide: "N" | "S" | "W" | "E" | null;
metaRev: number;
stateRev: number;
solved: boolean;
expanded: boolean;
scoreAwarded: number;
hasProgress: boolean;
specialFlags: {
crossing: boolean;
warp: boolean;
lock: boolean;
};
shop: null | {
cell: [number, number] | null;
itemIds: string[];
purchases: CompactPurchaseSummary[];
};
}
interface BoardPuzzleRecord {
epoch: string;
id: string;
metaRev: number;
revAuthor: string;
generatorVersion: number;
puzzle: StoredPuzzle;
}
interface BoardStateRecord {
epoch: string;
id: string;
stateRev: number;
revAuthor: string;
value: StoredBoardState;
}
```
`BoardIndexRecord` is the source for occupancy, distant rendering, solved
totals, shop icons, and inventory summaries. `summarizeBoard(meta, state)` is
the only function that creates it. Any transaction that changes a field used by
the summary must update the index in the same transaction.
Puzzle records remain separate from state records so drawing a path does not
rewrite the static puzzle definition.
### Startup and hydration
Startup proceeds in this order:
1. Open IndexedDB and read `WorldControl`, the active `WorldRecord`, recovery
coverage, and the cloud outbox header.
2. Read B0 and the saved camera/selected-board area first. If no camera anchor is
available, use B0.
3. Build initial occupancy and presentation from the first index page.
4. Mark the application ready once B0 and the initial viewport details are
hydrated.
5. Continue scanning `boardIndex` in 512-record pages and progressively add
occupancy and overview summaries.
Puzzle and state stores must never use an unbounded `getAll()` during startup.
Page scans resume from the final compound key of the previous page.
The current hydration contract is replaced by:
```ts
async function hydrateBoardDetails(
epoch: string,
id: string,
expected?: { metaRev: number; stateRev: number }
): Promise<HydratedBoard>
```
It reads the index, puzzle, and state in one readonly transaction. Missing rows
or revision mismatches are rejected rather than combined.
Details are hydrated for:
- Boards entering the interactive viewport and its prefetch margin.
- The selected or keyboard-focused board.
- Boards on both sides of an active gate connection.
- Boards involved in pending expansion or repair work.
- A shop before it opens or an inventory item before it is consumed.
The detail cache is an LRU limited to 128 boards or 32 MiB, whichever is reached
first. Active pointer boards, dirty boards, time-attack boards, and boards still
referenced by a renderer or editor are pinned. A dirty board must be committed
before it can be evicted.
Full-field score, inventory, minimap, shop-marker, and distant-overview loops
must use index summaries. They must not hydrate every board as a side effect.
### Commit and concurrency rules
All canonical field writes use one repository entry point:
```ts
async function commitFieldDelta(delta: FieldDelta): Promise<CommitResult>
```
The transaction:
1. Reads `WorldControl`.
2. Verifies both the expected active epoch and active format.
3. Applies puzzle, state, summary, global, recovery, tombstone, and outbox
changes.
4. Commits the updated revisions together.
An epoch or format mismatch aborts with `STALE_WORLD_EPOCH`.
The existing Web Lock and storage-lease fallback remain the cross-tab mutation
guard. BroadcastChannel and storage events are only wake-up signals; receivers
must re-read authoritative control and revision records from IndexedDB.
Compression, file writes, fetches, timers, worker replies, and rendering yields
must not be awaited inside an IndexedDB transaction. Browsers may auto-commit a
transaction when it has no pending IndexedDB request. Data should be parsed,
normalized, summarized, and encoded before its bounded transaction is opened.
### Recovery
IndexedDB remains authoritative. LocalStorage retains only:
- The active-epoch hint.
- A bounded dirty-row recovery journal.
- Cross-tab wake-up data.
It no longer attempts to mirror the entire version-2 field. Recovery records
remain epoch-scoped and cannot be applied after an import, reset, or rollback
switches to another epoch.
## Portable archive
### Format
The default filename is:
```text
bend-field-save-YYYY-MM-DD.bfsave
```
The payload is UTF-8 NDJSON. It is wrapped in one gzip stream when
`CompressionStream("gzip")` is available and remains uncompressed otherwise.
The importer detects the gzip magic bytes and verifies that the decoded
manifest declares the same compression mode.
Decoded records have this exact order:
```json
{"type":"manifest","format":"bend-field-save","archiveVersion":2,"saveSchema":31,"gameplayVersion":3,"worldGeneration":"...","appVersion":"...","generatorVersion":1,"exportedAt":"...","boardCount":42,"estimatedRawBytes":123456,"encoding":"ndjson","compression":"gzip"}
{"type":"global","value":{}}
{"type":"board","id":"B0","meta":{},"state":{}}
{"type":"board","id":"B1","meta":{},"state":{}}
{"type":"end","boardCount":42,"rawBytes":123456,"crc32":"12ab34cd"}
```
Board records are sorted by numeric board ID. Exactly one global record and one
footer are required. No record is pretty-printed.
The footer CRC32 covers every decoded UTF-8 byte before the footer, including
record newlines. Gzip validation, CRC32, deterministic ordering, byte counts,
and board counts detect accidental corruption and truncation. They do not
authenticate the archive's author.
Version 2 intentionally has no random-access index, encryption, signature, or
per-block hash. Import consumes every record in order, so a custom container
would add complexity without improving the current use case.
### Portable allowlist
Portable board metadata includes geometry, levels, seed, puzzle definition,
generator version, and connection-related data. Portable board state includes
paths, special progress, solved/expanded status, rewards, and shop purchases.
Portable global state includes gameplay version, bonus events,
encountered mechanics, last-solve information, time-attack gameplay state, and
the trusted clock floor.
The importer recomputes `nextId`, solved count, total score, bonus score, field
bounds, board summaries, and storage-size estimates.
The following data is always excluded:
- `cloudProfile`, player ID, token, cloud revision, cloud pending data, and
outbox rows.
- Recovery envelopes, journals, coverage markers, and tombstones.
- `worldEpoch`, `sessionId`, revisions' author identities, and transport
revision state.
- Origin URL.
- Quarantine diagnostics, debug flags, performance data, and transient caches.
- Cosmetic preferences that are not part of the field.
Portable records omit local revisions and authors. Import assigns a fresh epoch
and fresh local revisions.
### Export pipeline
The export button obtains its destination synchronously while it still has user
activation. Destination priority is:
1. `showSaveFilePicker()` and a direct `FileSystemWritableFileStream`.
2. An OPFS temporary file whose resulting `File` is downloaded.
3. An in-memory Blob only when the projected archive is at most 64 MiB.
All capabilities are feature-detected. Lack of gzip support selects identity
encoding; it does not change the archive record format.
After a destination is available, export:
1. Flushes pending field writes and aborts if the flush fails.
2. Acquires the exclusive world lock.
3. Captures the active epoch, global record, board count, and size estimate.
4. Reads board IDs in deterministic pages using short readonly transactions.
5. Encodes records through a worker and writes them with stream backpressure.
6. Writes the footer, closes the destination, and releases the lock.
Canonical database writes are queued while the lock is held. The archive
therefore represents the committed field at export start. The UI may allow
navigation but must pause puzzle edits, purchases, field placement, reset, and
time-attack mutations until export finishes or is canceled.
Worker input uses approximately 1 MiB chunks with at most two chunks in flight.
The worker performs JSON serialization, parsing, and CRC work. Native streams
perform compression and decompression.
## Import and activation
### Inspection
`inspectFieldArchive(file, { signal })` reads enough of the file to display:
- Archive and save-schema versions.
- Export date.
- World generation.
- Board count.
- Estimated raw size.
- Compression mode.
Inspection does not trust the declared values and does not activate anything.
The complete stream is validated during staging.
### Limits
Version-2 import enforces:
- Compressed file size at most 1 GiB.
- Decoded content at most 2 GiB.
- A single decoded record at most 8 MiB.
- Expansion ratio at most 100:1.
- At most `MAX_BOARDS`, currently 200,000.
- Existing board-coordinate, chunk-shape, puzzle-cell, and path-count limits.
- Write batches of at most 256 boards or 4 MiB.
`navigator.storage.estimate()` provides an advisory preflight. Import requests
approximately twice the estimated staged size plus 32 MiB of headroom because
the old and new fields coexist during validation. The actual IndexedDB result
remains authoritative: `QuotaExceededError` aborts staging without deleting the
active field.
Large or version-2 restores require IndexedDB. There is no destructive
localStorage-only fallback.
### Strict validation
Each record is treated as untrusted data. Version-2 restore fails closed for:
- Invalid archive identity, ordering, or unsupported version.
- A schema/world-generation pair without an explicit migration.
- Duplicate or noncanonical board IDs.
- Missing or invalid B0.
- Missing metadata or state.
- Invalid puzzle bounds, gates, cells, paths, stores, or references.
- Overlapping world chunks.
- Board, byte, line, cell, path, or expansion-ratio limits.
- A missing footer or incorrect board count, byte count, or CRC.
Unlike current tolerant snapshot normalization, a damaged version-2 archive
never silently drops a board.
Validation maintains occupied chunks and derived totals while records are
staged. After the final record, a database pass verifies staged counts,
revisions, summary/detail agreement, B0 hydration, and global aggregates.
`importFieldArchive()` stops after producing a completely validated `ready`
epoch. It never changes the active pointer; activation is a separate operation
after the replacement confirmation.
### Staging and atomic activation
Import creates a new local epoch with `status: "staging"`. The epoch from the
archive is never reused. Each validated batch is committed independently and
updates resumable progress on its `WorldRecord`.
After complete validation, the epoch becomes `ready`. Activation then acquires
the world lock and uses one short transaction to:
1. Re-read the expected active epoch and global revision.
2. Move the current epoch to `rollback`.
3. Mark the ready epoch `active`.
4. Set `previousEpoch`.
5. Flip `WorldControl.activeEpoch`.
6. Mark the activation as unverified.
Only after this transaction commits may localStorage hints and cross-tab
replacement messages be updated.
The application reloads into the new epoch. It verifies the header, first index
page, B0 puzzle/state, and occupancy before enabling mutations. Failure at this
point atomically restores `previousEpoch`.
After the new epoch completes its first durable gameplay checkpoint,
`activationVerified` becomes true and the old epoch becomes garbage. At most
one rollback epoch is retained, and it is not presented as save history.
Canceled, interrupted, corrupt, quota-failing, or worker-failing imports leave
the active pointer unchanged. Abandoned staging epochs are marked for cleanup
and removed after 24 hours.
Restored archives start local-only with no cloud profile or outbox. Reconnecting
cloud sync requires an explicit player action and a separately designed paged
full-sync protocol. The current 10,000-change push path must not be used to
silently upload a very large restored world.
### Legacy JSON
Existing JSON envelopes and raw snapshots remain importable and retain the
current 100 MiB file limit. They are sanitized before conversion and pass
through the same epoch-staging and activation pipeline.
Compatibility is handled through explicit
`migrateArchiveRecord(fromSchema, record)` registrations. An unknown schema or
world generation is rejected instead of silently creating a fresh field.
Legacy normalization may repair or discard malformed data. When that occurs,
the importer displays a lossy-import report with totals and the first affected
board IDs. Activation requires separate confirmation of that report. New
version-2 archives never use lossy repair.
## Migration from the current database
The version-change transaction creates stores and indexes only. It must not copy
an entire field inside `onupgradeneeded`.
While `activeFormat` is 1:
1. Startup continues using the existing stores.
2. One tab becomes migration leader through the existing world lock.
3. A new version-2 staging epoch is created.
4. Legacy rows are copied in resumable batches.
5. Normal commits dual-write legacy and version-2 records in the same
transaction.
6. Backfill uses revision-conditional writes so it cannot overwrite newer
dual-written data.
7. The last legacy cursor key and copied counts are persisted after every
batch.
After board, global, tombstone, recovery, and outbox counts and revisions
reconcile, the persistence queue is flushed and `activeFormat` switches to 2 in
one transaction. Dual-writing continues until the first version-2 activation
verification checkpoint.
Quota failure, a blocked upgrade, or interruption leaves format 1 authoritative
and retryable. The old stores remain for one application release and are
removed only by a later IndexedDB version upgrade.
## Garbage collection
Garbage collection:
- Never deletes the active, previous, or recovery-pinned epoch.
- Deletes no more than 500 rows per idle batch.
- Persists the current store and key after every batch.
- Resumes after reload or interruption.
- Removes abandoned staging epochs older than 24 hours.
- Removes a verified rollback epoch after the new field's first checkpoint.
- Cleans failed OPFS export files.
## Interfaces
The repository and archive modules expose these conceptual operations:
```ts
loadActiveWorldHeader(): Promise<{
control: WorldControl;
world: WorldRecord;
}>;
scanBoardIndex(
epoch: string,
afterKey?: IDBValidKey,
limit?: number
): Promise<BoardIndexRecord[]>;
hydrateBoardDetails(
epoch: string,
id: string,
expectedRevisions?: { metaRev: number; stateRev: number }
): Promise<HydratedBoard>;
commitFieldDelta(delta: FieldDelta): Promise<CommitResult>;
inspectFieldArchive(
file: File,
options?: { signal?: AbortSignal }
): Promise<ArchiveSummary>;
exportFieldArchive(options: {
writable: WritableStream<Uint8Array>;
signal?: AbortSignal;
onProgress?: (progress: FieldArchiveProgress) => void;
}): Promise<ExportSummary>;
importFieldArchive(options: {
file: File;
signal?: AbortSignal;
onProgress?: (progress: FieldArchiveProgress) => void;
}): Promise<ImportSummary>;
activateStagedWorld(
stagedEpoch: string,
expectedActive: { epoch: string; globalRev: number }
): Promise<void>;
rollbackUnverifiedActivation(expectedEpoch: string): Promise<void>;
migrateLegacyWorld(): Promise<void>;
collectWorldGarbage(): Promise<void>;
```
Progress has the following stable shape:
```ts
interface FieldArchiveProgress {
phase:
| "prepare"
| "encode"
| "write"
| "validate"
| "stage"
| "activate"
| "cleanup";
boardsDone: number;
boardsTotal: number;
bytesRead: number;
bytesWritten: number;
}
```
Progress is reported at least every 250 ms while work is advancing.
Cancellation is acknowledged within one worker chunk or one database batch.
## Performance budgets
- Initial gameplay readiness requires only the field header, first index page,
and initial viewport details.
- Startup does not load every puzzle or state.
- Archive import/export adds at most 32 MiB of JavaScript heap beyond the
resident field summaries and browser stream buffers.
- At most two approximately 1 MiB worker chunks are in flight.
- No archive or migration task occupies the main thread for 50 ms or longer.
- IndexedDB writes contain at most 256 boards or 4 MiB.
- Index scans use at most 512 records per transaction.
- Import and export UI remains cancelable throughout encode, write, validation,
and staging.
## Verification
### Semantic and privacy tests
- Gzip and identity-encoded round trips preserve the complete portable gameplay
field.
- Derived totals after import equal totals recomputed from the original field.
- Archive text and decoded records contain none of the excluded credential,
recovery, epoch, session, debug, or transport fields.
- Numeric board ordering and CRC output are deterministic for identical
portable input.
### Invalid archive tests
- Corrupt gzip data.
- Truncated records or missing footer.
- Incorrect CRC, byte count, or board count.
- Reordered, duplicated, or unknown records.
- Duplicate IDs, missing B0, and overlapping geometry.
- Malformed puzzles, paths, stores, and references.
- Oversized file, decoded stream, line, board count, and expansion ratio.
- Unsupported archive, save schema, or world-generation combination.
### Failure and concurrency tests
- Cancellation and injected failure before and after every staging batch.
- Quota failure during preflight and during a write transaction.
- Worker termination and destination-write failure.
- Crash before activation, during pointer activation, and before verification.
- Automatic rollback after failed B0 or first-index validation.
- Stale-tab writes after import, reset, rollback, or migration cutover.
- Cross-tab field replacement and blocked database upgrade.
- Migration resume, dual-write conflict, and conditional backfill.
- Garbage-collection resume and protection of active/rollback epochs.
- Cloud remains disconnected after portable restore.
### Lazy-load tests
- B0 becomes interactive before the complete index scan finishes.
- Details hydrate for viewport, gate adjacency, repair, inventory, and shop
access.
- Missing or mismatched revisions fail hydration.
- Dirty, active, and referenced boards cannot be evicted.
- Full-field summaries do not cause detail hydration.
### Browser and scale tests
- Direct file picker, OPFS, and bounded Blob output paths.
- Gzip and identity compression paths.
- Web Lock and storage-lease concurrency paths.
- 10,000-board round trip and startup coverage in continuous integration.
- 100,000-board and 200,000-board browser benchmarks as scheduled tests.
- Long-task, heap, progress-frequency, cancellation-latency, transaction-size,
and startup-readiness budgets.
Existing local `BEND_PERF` instrumentation should record only aggregate
duration, byte, board, cancellation, quota, rollback, and cleanup metrics. No
field contents, board IDs, player identity, or remote telemetry are added.
## Current implementation requirements
1. Add the version-6 stores, repository interfaces, epoch checks, and resumable
migration without changing the active read path.
2. Enable summary-first startup, real detail hydration, summary-based
full-field operations, and the bounded LRU.
3. Add the streamed `.bfsave` exporter and all output sinks.
4. Add strict staged import, atomic activation, rollback, and legacy conversion.
5. Route reset through the same epoch activation path.
6. Garbage collection removes inactive epochs and obsolete stores only after verified activation.
7. Cloud synchronization uses paged pulls, bounded push batches, recent-revision deltas, and per-board versioned server storage.

View file

@ -0,0 +1,47 @@
# Bend Field interaction performance and persistence
## Problems addressed
- Cursor movement felt uneven.
- Camera panning could appear to stop redrawing during a long held gesture.
- Pickup dragging needed a stable upper frame-rate limit.
- Zoomed-out play could exhaust its cached field image while the camera was still moving.
- A solved puzzle could later return to an unsolved state.
- Puzzle gates still had a dormant debug scheduling switch.
## Implemented solutions
### Cursor, camera, and pickup cadence
- Cursor rendering now keeps only the newest pointer sample and commits it on a display frame.
- Cursor commits use the shared 33.33 ms visual interval, limiting presentation to 30 FPS even on high-refresh displays.
- Camera commits use the same 30 FPS ceiling with a separate missed-frame fallback.
- Pickup dragging remains on the bounded 30 FPS visual scheduler and keeps logical catch-up work separate from visual pointer tracking.
### Continuous overview panning
- The zoomed-out field still pans by transforming a cached bitmap, avoiding a full map redraw on every pointer event.
- When a held pan reaches the bitmap's overscan boundary, the game now requests a cache rebuild during the gesture.
- These rebuilds run through an idle callback, are separated by at least 180 ms, and are capped by the benchmark. Camera transforms continue while the new bitmap is prepared.
### Durable puzzle completion
- A durable `solved: true` value is now monotonic for the same puzzle.
- Route sanitization may remove damaged path data, but it no longer revokes the clear, score, solver identity, or store state.
- Existing cloud-pull, cross-tab, recovery-journal, and board-hydration merges continue to preserve compatible solved records.
### Production-only puzzle gates
- The `SPECIAL_CELL_DEBUG_ALL_LEVELS` switch was removed.
- Internal gates and other special cells now use only the normal production level schedule, beginning at level 5.
## Verification
- Focused regression coverage executes the in-gesture cache refresh, durable-clear sanitization, and production-only gate schedule.
- The complete fast test suite passes.
- The real Edge benchmark gates cursor, camera, and pickup presentation at the shared 30 FPS ceiling, checks input age and camera work separately, and rejects uncapped presentation.
- Edge and benchmark server processes are checked after every browser run; the final count is zero.
## Remaining stress-test observation
The optional 4× CPU-throttled Edge scenario recorded one 106.70 ms functional-drag outlier. Its steady cadence (8.40 ms p95), model work (2.80 ms p95), visual work (4.90 ms p95), and render work (2.60 ms p95) stayed within their individual budgets. This outlier should remain visible in future profiling rather than being hidden by a relaxed acceptance threshold.

View file

@ -87,6 +87,18 @@ The field uses several levels of detail:
Camera updates, board drag frames, the minimap, distant overview, presence cursors, reactions, and static noise are scheduled and instrumented independently. Difficulty-field overlays do not exist in the current runtime.
### Effects and cosmetics
Reaction rendering keeps the complete `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet` artwork at the existing 30 FPS cap. Accepted local reactions are sent to nearby realtime subscribers; reactions created while the socket is reconnecting are queued until their visual lifetime expires. Each accepted reaction receives one deterministic prepared model containing its immutable geometry and trigonometry. Frames reuse that model, draw every valid visible reaction in original layer order, and derive lifetime from authoritative timestamps. Overload is measured but never changes style, duration, particle count, glow, or compositing.
The reaction scheduler uses absolute deadlines, one pending timer, and one pending animation-frame callback. It stops while the page is hidden and resumes from current time. The renderer retains full-canvas clearing because cropped clears and cached transformed glyphs did not satisfy the visual-equivalence gate. A bounded emoji cache is used only for transform-neutral draws; rotated or scaled glyphs use direct browser text rendering. A separate bounded LRU `Path2D` cache reuses fixed Orbit and Firework geometry, with direct path construction as the exact fallback.
Aurora is a shop-only line-color contract. Its curated palette advances once every two seconds and colors newly drawn Aurora paths, connectors, endpoints, and usable gates. Updates are scoped to the world element and run only while tracked Aurora presentation nodes are visible. Completion flashes, completion bursts, and gem particles use bounded reusable node pools with overflow cleanup; gem particles share immutable keyframe and option templates while per-particle values live on the reused nodes. The cosmetic inventory keeps keyed category/item nodes, skips unchanged view signatures, patches only the old and new equipped cards, preserves focus and scroll position, lazy-decodes flag images, and applies render containment to offscreen cards.
`BEND_PERF` reports reaction preparation and publication, style, compositing, frame cadence, scheduler callbacks, visible/active counts, glyph and path-cache entries/hits/misses/evictions, Aurora ticks and writes, pooled-node setup/activity, and inventory patch/render timings.
The item UI contains no debug toggle. Opening `/debug-items` enables the debug purchase economy for that URL only: normal shop stock and purchase records are retained, while affordability checks settle at zero cost.
## Persistence
IndexedDB is the durable local source of truth. A compact local mirror supports recovery when IndexedDB startup or writes fail.
@ -109,7 +121,7 @@ The world-generation identifier is separate from the application version. Ordina
`test/run-all.js` runs source, gameplay, economy, performance, storage, concurrency, generation, interaction, reset, special-cell, and server regressions.
`test/browser-performance-benchmark.js` verifies real Edge startup and measures pointer work, camera work, minimap conversion, level-of-detail processing, persistence, overview rendering, DOM size, and main-thread responsiveness.
`test/browser-performance-benchmark.js` verifies real Edge startup and measures pointer work, camera work, minimap conversion, level-of-detail processing, persistence, overview rendering, DOM size, main-thread responsiveness, every reaction style, four/eight-effect overlap, deterministic checkpoint visual equivalence, reaction/Gem/completion setup cost, Aurora active/inactive/hidden behavior, localized cosmetic inventory updates with focus and scroll retention, effect-plus-interaction scenarios, and 100-cycle cleanup. It writes the complete report to `test-results/browser-performance-benchmark.json` unless `BEND_FIELD_BENCHMARK_OUTPUT` overrides the path.
## Documentation policy

53
docs/test-policy.md Normal file
View file

@ -0,0 +1,53 @@
# Test tiers and source-guard inventory
The release pipeline has four explicit tiers:
- `npm run test:fast` — deterministic unit, contract, persistence, server, and
source-policy-compatible regression tests; no browser is launched.
- `npm run test:browser` — the required real-browser interaction/performance
matrix plus the store UI flow. Each runner owns one temporary profile and one
process tree, closes it in `finally`, and never targets an unrelated Edge
process. The UI runner uses `playwright-core` only as a driver for the
system-provided Edge binary; it does not download a second browser.
- `npm run test:storage` — opt-in large IndexedDB/archive scale coverage.
- `npm run test:ci` — source policy, fast suite, and required browser release
behavior. CI sets the small bounded browser profile and runs one job at a
time.
## Effects and cosmetics release gates
The browser performance runner loads deterministic checkpoints from `test/fixtures/effect-visual-checkpoints.json` and exercises all six reaction styles at normalized lifetimes of 0.10, 0.25, 0.50, 0.75, and 0.95. Direct main-thread rendering with glyph and path caches bypassed is the correctness reference. Cached output may differ only within the anti-aliasing tolerance, with at least 99.5% of pixels inside that tolerance. Structural reductions, style substitution, dropped overlap, shorter lifetimes, or automatic quality tiers are release failures.
Normal-speed Edge gates enforce the 30 FPS cadence, per-style work limits, the four-effect overlap budget, full rendering of eight simultaneous valid effects, bounded scheduler callback rates, reaction publication p95 at or below 2 ms, Gem and completion setup p95 at or below 3 ms, localized inventory patch p95 at or below 8 ms, exact inventory focus/scroll/node retention, Aurora active-write and inactive/hidden zero-work behavior, effect-plus-pan/zoom/pickup/edge-pan operation, and zero owned state after a 100-cycle cleanup stress. The settled heap delta must remain at or below 2 MB after the bounded caches are cleared. The 4x CPU profile is an overload diagnostic: it enforces bounded callbacks, complete rendering, and cleanup, but it does not permit the test or runtime to reduce visuals merely to meet normal-speed timing.
`effects-performance-smoke-test.js` is a documented repository-policy guard. Its source assertions protect the no-auto-degradation contract, bounded glyph/path caches, shared animation templates, localized inventory patches, and cleanup/lifecycle mechanisms that do not yet have a smaller public module seam. The browser runner always writes its report to `test-results/browser-performance-benchmark.json` unless `BEND_FIELD_BENCHMARK_OUTPUT` supplies another path, including when a gate fails after measurement.
## Source-shape guard inventory
The historical `source-smoke` and versioned `v47xx` files contain temporary
implementation-shape tripwires. They remain only where no stable public seam
exists yet. Their common reason is to prevent a known expensive or unsafe path
from being accidentally restored. Their removal condition is one of:
1. a pure module has a behavioral unit test;
2. a browser test measures the user-visible DOM, timing, or computed style;
3. a protocol/storage integration test covers the invariant; or
4. a generated artifact equality test covers the contract.
The following guards have already moved to public seams:
| Area | Public seam | Replacement coverage |
|---|---|---|
| Pointer ownership | `createGestureCoordinator` | `interaction-ownership-test.js` |
| Interaction scopes | `createInteractionState` | `interaction-ownership-test.js` |
| 60 Hz latest-value scheduling | `createFrameScheduler` | `frame-drag-scheduler-test.js` and browser cadence probes |
| Pickup lifecycle/queue | `createDragScheduler` | `frame-drag-scheduler-test.js` and release-drain behavior |
| Cursor identity/presentation | `createCursorModel` | `architecture-boundaries-test.js` and browser cursor probes |
| HTTP dispatch | `createHttpRouter` | `architecture-boundaries-test.js` and server integration |
| Authentication | `createAuthenticator` | `architecture-boundaries-test.js` and server security integration |
| Atomic JSON storage | `createJsonRepository` | recovery and server integration tests |
When touching a remaining source assertion, migrate it to the nearest seam and
delete the old assertion in the same change. New tests must not parse function
source unless they enforce a documented repository policy that cannot be
expressed as behavior.

View file

@ -1,13 +1,7 @@
'use strict';
const encoder=new TextEncoder(),crcTable=new Uint32Array(256),MAX_CHUNK_BYTES=1024*1024;
for(let index=0;index<256;index++){
let value=index;
for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1;
crcTable[index]=value>>>0;
}
importScripts('archive-codec.js');
const{encodeRecord,crc32Update,crc32Hex,parseRecordLine}=self.BendArchiveCodec,MAX_CHUNK_BYTES=1024*1024;
let crc=0xffffffff,rawBytes=0;
function crc32Update(value,bytes){let next=value>>>0;for(const byte of bytes)next=crcTable[(next^byte)&255]^(next>>>8);return next>>>0}
function crc32Hex(){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')}
function splitBytes(parts){
const chunks=[];let current=new Uint8Array(MAX_CHUNK_BYTES),offset=0;
for(const bytes of parts){let sourceOffset=0;while(sourceOffset<bytes.byteLength){const length=Math.min(current.byteLength-offset,bytes.byteLength-sourceOffset);current.set(bytes.subarray(sourceOffset,sourceOffset+length),offset);offset+=length;sourceOffset+=length;if(offset===current.byteLength){chunks.push(current.buffer);current=new Uint8Array(MAX_CHUNK_BYTES);offset=0}}}
@ -18,15 +12,15 @@ self.onmessage=event=>{
try{
if(message.op==='encode'){
const parts=[];
for(const record of message.records||[]){const bytes=encoder.encode(`${JSON.stringify(record)}\n`);if(message.includeInCrc!==false){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}parts.push(bytes)}
const chunks=splitBytes(parts);self.postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks);return;
for(const record of message.records||[]){const bytes=encodeRecord(record);if(message.includeInCrc!==false){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}parts.push(bytes)}
const chunks=splitBytes(parts);self.postMessage({id,chunks,rawBytes,crc32:crc32Hex(crc)},chunks);return;
}
if(message.op==='parse'){
const items=[];
for(const sourceLine of message.lines||[]){const line=sourceLine.replace(/\r$/,'');let record;try{record=JSON.parse(line)}catch(_){throw new Error('An archive record is not valid JSON.')}const bytes=encoder.encode(`${line}\n`);if(record?.type!=='end'){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}items.push({record,byteLength:bytes.byteLength,rawBytes,crc32:crc32Hex()})}
self.postMessage({id,items,rawBytes,crc32:crc32Hex()});return;
for(const sourceLine of message.lines||[]){const{record,bytes}=parseRecordLine(sourceLine);if(record?.type!=='end'){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}items.push({record,byteLength:bytes.byteLength,rawBytes,crc32:crc32Hex(crc)})}
self.postMessage({id,items,rawBytes,crc32:crc32Hex(crc)});return;
}
if(message.op==='snapshot'){self.postMessage({id,rawBytes,crc32:crc32Hex()});return}
if(message.op==='snapshot'){self.postMessage({id,rawBytes,crc32:crc32Hex(crc)});return}
throw new Error('Unknown archive worker operation.');
}catch(error){self.postMessage({id,error:error?.message||String(error)})}
};

View file

@ -11,22 +11,9 @@
const PROGRESS_INTERVAL=250;
const WORKER_TARGET_BYTES=1024*1024;
const MAX_WORKER_RECORDS=32;
const encoder=new TextEncoder();
const crcTable=new Uint32Array(256);
for(let index=0;index<256;index++){
let value=index;
for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1;
crcTable[index]=value>>>0;
}
function crc32Update(crc,bytes){
let value=crc>>>0;
for(const byte of bytes)value=crcTable[(value^byte)&255]^(value>>>8);
return value>>>0;
}
function crc32Hex(crc){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')}
const ArchiveCodec=global.BendArchiveCodec||(typeof module==='object'&&module.exports?require('./archive-codec'):null);
if(!ArchiveCodec)throw new Error('BendArchiveCodec is not loaded');
const{encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}=ArchiveCodec;
function abortError(){return new DOMException('The operation was canceled.','AbortError')}
function throwIfAborted(signal){if(signal?.aborted)throw signal.reason||abortError()}
function archiveError(message,code='INVALID_ARCHIVE'){const error=new Error(message);error.code=code;return error}
@ -38,7 +25,6 @@
if(!Number.isSafeInteger(record.estimatedRawBytes)||record.estimatedRawBytes<0||record.estimatedRawBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The archive size declaration is invalid.','ARCHIVE_TOO_LARGE');
return record;
}
function encodeRecord(record){return encoder.encode(`${JSON.stringify(record)}\n`)}
function boardNumber(id){return/^B(?:0|[1-9]\d*)$/.test(id)?Number(id.slice(1)):-1}
function triggerDownload(file,name){
const url=URL.createObjectURL(file),link=document.createElement('a');
@ -58,7 +44,7 @@
function createArchiveCodec(){
if(typeof Worker!=='function')return null;
let worker;try{worker=new Worker('field-persistence-worker.js?v=47.77')}catch(_){return null}
let worker;try{worker=new Worker('field-persistence-worker.js')}catch(_){return null}
let nextId=0;const pending=new Map();
worker.onmessage=event=>{const message=event.data||{},entry=pending.get(message.id);if(!entry)return;pending.delete(message.id);if(message.error)entry.reject(archiveError(message.error,'ARCHIVE_WORKER'));else entry.resolve(message)};
worker.onerror=event=>{const error=archiveError(event?.message||'The archive worker failed.','ARCHIVE_WORKER');for(const entry of pending.values())entry.reject(error);pending.clear()};
@ -232,10 +218,10 @@
const flushLines=async()=>{
if(!pendingLines.length)return;const lines=pendingLines;pendingLines=[];pendingLineBytes=0;
if(codec){const result=await codec.parse(lines);for(const item of result.items){if(item.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');await processRecord(item.record,item)}return}
for(const sourceLine of lines){const line=sourceLine.replace(/\r$/,'');if(!line)throw archiveError('The archive contains an empty record.');const lineBytes=encoder.encode(`${line}\n`);if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let record;try{record=JSON.parse(line)}catch(_){throw archiveError('An archive record is not valid JSON.')}if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})}
for(const sourceLine of lines){let parsed;try{parsed=parseRecordLine(sourceLine)}catch(error){throw archiveError(error.message)}const{line,record,bytes:lineBytes}=parsed;if(!line)throw archiveError('The archive contains an empty record.');if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})}
};
try{
while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encoder.encode(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}}
while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encodeUtf8(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}}
buffer+=decoder.decode();if(buffer){const estimatedBytes=buffer.length*3+1;if(pendingLines.length&&pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES)await flushLines();pendingLines.push(buffer);pendingLineBytes+=estimatedBytes}await flushLines();if(phase!=='done'||!footer)throw archiveError('The archive footer is missing or incomplete.');report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0},true);return{manifest,globalState,footer,boardCount:boardsDone,decodedBytes,compression:decoded.compression,worker:codec!=null};
}finally{try{reader.releaseLock()}catch(_){}codec?.terminate()}
}

View file

@ -3,21 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="bend-field-cloud-api" content="off">
<title>&#x66F2;&#x7DDA;&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9; v47.77</title>
<title>LinkField/リンクフィールド</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="favicon.ico">
<link rel="stylesheet" href="style.css?v=47.77">
<link rel="stylesheet" href="style.css">
</head>
<body class="loading" data-ready="false" data-loading-text="&#x8AAD;&#x307F;&#x8FBC;&#x307F;&#x4E2D;">
<div id="app">
<div id="topbar">
<div class="brand">&#x66F2;&#x7DDA;&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9; <small>v47.77</small></div>
<a class="room-back-link" href="https://host.nishi.boats/~333/">333の部屋に戻る</a>
<div class="brand">LinkField/リンクフィールド <small></small></div>
<div class="stat sr-data">&#x30AF;&#x30EA;&#x30A2; <b id="solvedCount">0</b></div>
<div class="stat score" aria-label="&#x6240;&#x6301;&#x6570;"><span aria-hidden="true">&#x25C6;</span> <b id="scoreCount">0</b></div>
<div class="stat sr-data">&#x76E4;&#x9762; <b id="worldCount">1</b></div>
<div class="stat fps-stat" aria-label="描画フレームレート"><b id="fpsCounter" data-fps="idle">FPS 待機</b></div>
<div class="stat compact-selected"><b id="selectedInfo" aria-live="polite">&#x30EC;&#x30D9;&#x30EB;1</b></div>
<div class="stat fps-stat" aria-label="FPS"><b id="fpsCounter" data-fps="idle">FPS 待機</b></div>
<div class="spacer"></div>
<div class="toolbar" aria-label="&#x30B2;&#x30FC;&#x30E0;&#x64CD;&#x4F5C;">
<button class="pill inventory-button" id="inventoryBtn" aria-label="&#x30A2;&#x30A4;&#x30C6;&#x30E0;&#x3092;&#x958B;&#x304F;" aria-haspopup="dialog" aria-expanded="false"><span aria-hidden="true">&#x25A3;</span><span>&#x30A2;&#x30A4;&#x30C6;&#x30E0; <b id="inventoryCount">0</b></span></button>
@ -25,38 +24,35 @@
<button class="pill settings" id="settingsBtn" type="button" aria-label="設定を開く" aria-haspopup="dialog" aria-expanded="false">⚙</button>
<button class="pill help" id="helpBtn" aria-label="&#x904A;&#x3073;&#x65B9;&#x3092;&#x958B;&#x304F;" aria-haspopup="dialog" aria-expanded="false">?</button>
<button class="pill player-name" id="playerNameBtn" type="button" title="プレイヤー名を変更">旅人</button>
<button class="pill cloud-status" id="cloudBtn" type="button" title="&#x540C;&#x671F;">&#x7AEF;&#x672B;&#x306E;&#x307F;</button>
</div>
<button id="saveStatus" type="button" aria-live="polite" aria-label="&#x4FDD;&#x5B58;&#x72B6;&#x614B;" title="&#x4FDD;&#x5B58;&#x72B6;&#x614B;&#x3092;&#x78BA;&#x8A8D;">&#x4FDD;&#x5B58;&#x6E08;&#x307F;</button>
</div>
<div id="viewport" tabindex="-1" aria-label="&#x66F2;&#x7DDA;&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9;"><canvas id="noiseCanvas" width="80" height="64" aria-hidden="true"></canvas><canvas id="reactionCanvas" aria-hidden="true"></canvas><canvas id="overviewCanvas" aria-hidden="true" hidden></canvas><div id="world"></div><canvas id="presenceCanvas" aria-hidden="true"></canvas></div>
<aside id="minimap" aria-label="&#x5468;&#x8FBA;&#x30DE;&#x30C3;&#x30D7;">
<div class="shared-indicator" id="cloudBtn" data-state="connecting" role="status" aria-live="off" aria-label="共有接続状態" title="共有サーバーへ接続中"><i aria-hidden="true"></i><span>共有</span></div>
<div id="viewport" tabindex="-1" aria-label="&#x66F2;&#x7DDA;&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9;"><canvas id="noiseCanvas" width="80" height="64" aria-hidden="true"></canvas><canvas id="reactionCanvas" aria-hidden="true"></canvas><canvas id="overviewCanvas" aria-hidden="true" hidden></canvas><div id="world"></div><canvas id="presenceCanvas" aria-hidden="true"></canvas><div id="boardHudLayer" aria-live="polite"></div></div>
<aside id="minimap" aria-label="&#x30DE;&#x30C3;&#x30D7;">
<div id="clearFeed" class="clear-feed" aria-live="polite" aria-label="共有クリア速報"></div>
<div class="minimap-head"><b>&#x5468;&#x8FBA;&#x30DE;&#x30C3;&#x30D7;</b><span id="minimapStatus">0 / 1</span></div>
<div class="minimap-head"><b>&#x30DE;&#x30C3;&#x30D7;</b></div>
<div class="minimap-controls" aria-label="&#x8996;&#x70B9;&#x79FB;&#x52D5;">
<button id="minimapOriginBtn" type="button">&#x539F;&#x70B9;</button>
<button id="minimapRandomBtn" type="button">&#x30E9;&#x30F3;&#x30C0;&#x30E0;</button>
</div>
<canvas id="minimapCanvas" width="210" height="132" role="application" tabindex="0" aria-label="&#x5468;&#x8FBA;&#x30DE;&#x30C3;&#x30D7;&#x3002;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x307E;&#x305F;&#x306F;&#x30C9;&#x30E9;&#x30C3;&#x30B0;&#x3067;&#x8996;&#x70B9;&#x3092;&#x79FB;&#x52D5;&#x3002;&#x77E2;&#x5370;&#x30AD;&#x30FC;&#x3067;&#x5FAE;&#x8ABF;&#x6574;&#x3002;"></canvas>
<div class="minimap-legend" aria-hidden="true"><span class="solved">&#x30AF;&#x30EA;&#x30A2;</span><span class="unsolved">&#x672A;&#x30AF;&#x30EA;&#x30A2;</span><span class="long-line">&#x9577;&#x8DDD;&#x96E2;&#x7DDA;</span></div>
<canvas id="minimapCanvas" width="210" height="132" role="application" tabindex="0" aria-label="&#x30DE;&#x30C3;&#x30D7;&#x3002;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x307E;&#x305F;&#x306F;&#x30C9;&#x30E9;&#x30C3;&#x30B0;&#x3067;&#x8996;&#x70B9;&#x3092;&#x79FB;&#x52D5;&#x3002;&#x5E97;&#x8217;&#x30DE;&#x30FC;&#x30AB;&#x30FC;&#x3092;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3059;&#x308B;&#x3068;&#x30B7;&#x30E7;&#x30C3;&#x30D7;&#x3092;&#x958B;&#x304D;&#x307E;&#x3059;&#x3002;"></canvas>
<div class="minimap-legend" aria-hidden="true"><span class="solved">&#x30AF;&#x30EA;&#x30A2;</span><span class="unsolved">&#x672A;&#x30AF;&#x30EA;&#x30A2;</span><span class="long-line">&#x9577;&#x8DDD;&#x96E2;&#x7DDA;</span><span class="shop">&#x30B7;&#x30E7;&#x30C3;&#x30D7;</span></div>
</aside>
<div id="reactionRadial" role="menu" aria-label="リアクションを選択" hidden></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="timeAttackCountdownOverlay" aria-live="assertive" aria-atomic="true" hidden><span></span></div>
<div id="specialTooltip" role="tooltip" hidden></div>
<section id="statusPanel" hidden aria-live="assertive">
<p id="statusMessage"></p>
<div class="status-actions">
<button class="pill" id="retryBtn" type="button">&#x518D;&#x8A66;&#x884C;</button>
<button class="pill" id="exportBtn" type="button">&#x66F8;&#x304D;&#x51FA;&#x3057;</button>
<button class="pill" id="importBtn" type="button">&#x8AAD;&#x307F;&#x8FBC;&#x307F;</button>
<input id="importFile" type="file" accept="application/x-bend-field-save,.bfsave,application/json,.json" hidden>
<button class="pill danger" id="freshBtn" type="button">&#x6700;&#x521D;&#x304B;&#x3089;</button>
<button class="pill" id="dismissStatusBtn" type="button">&#x9589;&#x3058;&#x308B;</button>
</div>
</section>
<div id="modal" aria-hidden="true" inert>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="helpTitle" aria-describedby="helpDescription" tabindex="-1">
<h2 id="helpTitle">&#x66F2;&#x7DDA;&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9;</h2>
<h2 id="helpTitle">LinkField/リンクフィールド</h2>
<div id="helpDescription">
<div class="tutorial-grid">
<article class="tutorial-card">
@ -101,7 +97,6 @@
<div id="inventoryModal" aria-hidden="true" inert>
<div class="panel inventory-panel" role="dialog" aria-modal="true" aria-labelledby="inventoryTitle" tabindex="-1">
<div class="inventory-heading"><div><small>&#x30A2;&#x30A4;&#x30C6;&#x30E0;</small><h2 id="inventoryTitle">&#x6240;&#x6301;&#x30A2;&#x30A4;&#x30C6;&#x30E0;</h2></div><strong id="inventoryTotal">0</strong></div>
<label class="debug-items-toggle"><input id="debugAllItemsToggle" type="checkbox"><span><b>DEBUG</b><small id="debugAllItemsState">OFF · 通常所持数を使用</small></span></label>
<p id="inventoryTarget">&#x30AB;&#x30FC;&#x30BD;&#x30EB;&#x306F;&#x30C7;&#x30B6;&#x30A4;&#x30F3;&#x3092;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3057;&#x3066;&#x5207;&#x308A;&#x66FF;&#x3048;&#x307E;&#x3059;&#x3002;</p>
<div id="inventoryList" class="inventory-list"></div>
<div class="modal-actions"><button class="pill close" id="closeInventory" type="button">&#x9589;&#x3058;&#x308B;</button></div>
@ -110,28 +105,28 @@
<div id="settingsModal" aria-hidden="true" inert>
<div class="panel settings-panel" role="dialog" aria-modal="true" aria-labelledby="settingsTitle" tabindex="-1">
<div class="settings-heading"><div><small>SETTINGS</small><h2 id="settingsTitle">設定</h2></div></div>
<div class="settings-heading"><div><small>設定</small><h2 id="settingsTitle">設定</h2></div></div>
<label class="settings-field"><span>プレイヤー名</span><input id="settingsPlayerName" type="text" maxlength="24" autocomplete="nickname" spellcheck="false"></label>
<label class="settings-toggle"><input id="lightweightRenderingToggle" type="checkbox"><span><b>軽量描画</b><small>背景ノイズ、光、粒子など一部の視覚効果を抑えます。</small></span></label>
<label class="settings-toggle"><input id="soundEnabledToggle" type="checkbox"><span><b>効果音</b><small>操作音とクリア音を再生します。</small></span></label>
<div class="modal-actions"><button class="pill" id="saveSettings" type="button">保存</button><button class="pill close" id="closeSettings" type="button">閉じる</button></div>
<div class="modal-actions settings-actions"><button class="pill quiet" id="resetSettings" type="button">初期化</button><button class="pill close" id="closeSettings" type="button">閉じる</button></div>
</div>
</div>
<div id="timeAttackModal" aria-hidden="true" inert>
<div class="panel time-attack-panel" role="dialog" aria-modal="true" aria-labelledby="timeAttackTitle" tabindex="-1">
<div class="time-attack-heading"><div><small>TIME ATTACK</small><h2 id="timeAttackTitle">タイムアタック</h2></div><strong id="timeAttackHeadingScore">タイム加算</strong></div>
<div class="time-attack-heading"><div><small>タイムアタック</small><h2 id="timeAttackTitle">タイムアタック</h2></div><strong id="timeAttackHeadingScore">挑戦結果</strong></div>
<section id="timeAttackSetup">
<p class="time-attack-intro">制限時間内に盤面を解き、獲得ジェムを伸ばします。集めた量に応じて報酬倍率が上がります。</p>
<p class="time-attack-intro">制限時間内に盤面を解き、ジェムを伸ばします。獲得ジェムに応じて次の通り倍率が上昇します。</p>
<p class="time-attack-kicker">コースを選択</p>
<div class="time-attack-durations">
<button type="button" data-time-minutes="3"><span class="duration-value"><b>3</b><small>分</small></span><strong>QUICK</strong><em class="duration-status">短時間で集中</em></button>
<button type="button" data-time-minutes="5"><span class="duration-value"><b>5</b><small>分</small></span><strong>STANDARD</strong><em class="duration-status">標準コース</em></button>
<button type="button" data-time-minutes="10"><span class="duration-value"><b>10</b><small>分</small></span><strong>LONG</strong><em class="duration-status">じっくり挑戦</em></button>
<button type="button" data-time-minutes="3"><span class="duration-value"><b>3</b><small>分</small></span><strong>短時間</strong><em class="duration-status">短時間で集中</em></button>
<button type="button" data-time-minutes="5"><span class="duration-value"><b>5</b><small>分</small></span><strong>標準</strong><em class="duration-status">標準コース</em></button>
<button type="button" data-time-minutes="10"><span class="duration-value"><b>10</b><small>分</small></span><strong>長時間</strong><em class="duration-status">じっくり挑戦</em></button>
</div>
<div class="time-attack-rules">
<div><b>獲得ジェムで倍率UP</b><span>時間内の合計獲得量に応じて、自動的に最も高い倍率が適用されます。</span></div>
<div><b>獲得ジェムの倍率UP</b><span>獲得ジェムに応じて次の通り倍率が上昇します。</span></div>
<div class="time-attack-ladder" aria-label="報酬倍率">
<span><small>獲得</small><b>0+</b><strong>×1.25</strong></span><span><small>獲得</small><b>100+</b><strong>×1.50</strong></span><span><small>獲得</small><b>250+</b><strong>×2.00</strong></span><span><small>獲得</small><b>500+</b><strong>×2.50</strong></span><span><small>獲得</small><b>1000+</b><strong>×3.00</strong></span>
<span><small>基礎累計</small><b>0+</b><strong>×1.25</strong></span><span><small>基礎累計</small><b>100+</b><strong>×1.50</strong></span><span><small>基礎累計</small><b>250+</b><strong>×2.00</strong></span><span><small>基礎累計</small><b>500+</b><strong>×2.50</strong></span><span><small>基礎累計</small><b>1000+</b><strong>×3.00</strong></span>
</div>
</div>
</section>
@ -141,8 +136,8 @@
<div class="time-attack-stats"><span>獲得ジェム <b id="timeAttackCollected">0</b></span><span>現在倍率 <b id="timeAttackMultiplier">×1.25</b></span><span>現在合計 <b id="timeAttackProjected">0</b></span></div>
</section>
<section id="timeAttackResult" hidden>
<p class="time-attack-kicker">RESULT</p>
<div class="time-attack-result-grid"><span>制限時間 <b id="timeAttackResultLimit">3分</b></span><span>クリア数 <b id="timeAttackResultSolves">0</b></span><span>獲得ジェム <b id="timeAttackResultCollected">0</b></span><span>実効倍率 <b id="timeAttackResultMultiplier">×1.25</b></span><span>タイム加算 <b id="timeAttackResultBonus">+0</b></span><span>合計 <b id="timeAttackResultTotal">0</b></span></div>
<p class="time-attack-kicker">結果</p>
<div class="time-attack-result-grid"><span>制限時間 <b id="timeAttackResultLimit">3分</b></span><span>クリア数 <b id="timeAttackResultSolves">0</b></span><span>合計 <b id="timeAttackResultTotal">0</b></span></div>
<pre id="timeAttackShareText" aria-label="タイムアタックの結果"></pre>
</section>
<div class="modal-actions time-attack-actions"><button class="pill" id="copyTimeAttack" type="button" hidden>📋 結果をコピー</button><button class="pill close" id="closeTimeAttack" type="button">&#x9589;&#x3058;&#x308B;</button></div>
@ -151,9 +146,20 @@
</div>
<div id="customEmojiCursor" aria-hidden="true"></div>
<div id="pickupHandleOverlay" aria-hidden="true"></div>
<script src="puzzle-core.js?v=47.77-5"></script>
<script src="app-logic.js?v=47.77"></script>
<script src="field-persistence.js?v=47.77"></script>
<script src="app.js?v=47.77"></script>
<script src="build-meta.js"></script>
<script src="shared-contracts.js"></script>
<script src="runtime-config.js"></script>
<script src="store-catalog.generated.js"></script>
<script src="puzzle-patterns.js"></script>
<script src="puzzle-core.js"></script>
<script src="app-logic.js"></script>
<script src="archive-codec.js"></script>
<script src="client/input/frame-scheduler.js"></script>
<script src="client/input/drag.js"></script>
<script src="client/input/interaction-state.js"></script>
<script src="client/input/gesture-coordinator.js"></script>
<script src="client/ui/cursor.js"></script>
<script src="field-persistence.js"></script>
<script src="app.js?v=48.0.3"></script>
</body>
</html>

View file

@ -1,11 +1,32 @@
{
"name": "bend-field-v47-shared-world",
"name": "link-field-shared-world",
"private": true,
"version": "47.77.0",
"version": "48.0.0",
"devDependencies": {
"playwright-core": "^1.54.0"
},
"scripts": {
"start": "node server.js",
"test": "node test/run-all.js",
"generate:build-meta": "node scripts/generate-build-meta.js",
"generate:catalog": "node scripts/generate-store-catalog.js",
"start": "node scripts/service-control.js start",
"test": "npm run test:fast",
"test:fast": "node test/run-all.js",
"test:browser": "node test/browser-performance-benchmark.js && node test/store-ui-browser-test.js",
"test:storage": "node test/browser-field-storage-benchmark.js",
"test:bridge": "node test/php-bridge-integration-test.js",
"smoke:public": "node scripts/public-smoke-test.js",
"test:policy": "node scripts/check-source-policy.js",
"test:ci": "npm run test:policy && npm run test:fast && npm run test:browser",
"benchmark:browser": "node test/browser-performance-benchmark.js",
"benchmark:field-storage": "node test/browser-field-storage-benchmark.js"
"benchmark:field-storage": "node test/browser-field-storage-benchmark.js",
"start:foreground": "node scripts/service-control.js foreground",
"stop": "node scripts/service-control.js stop",
"status": "node scripts/service-control.js status",
"restart": "node scripts/service-control.js restart",
"deploy": "node scripts/service-control.js deploy",
"backup": "node scripts/service-control.js backup",
"backups": "node scripts/service-control.js backups",
"restore": "node scripts/service-control.js restore",
"recover": "node scripts/service-control.js recover"
}
}

File diff suppressed because one or more lines are too long

3
puzzle-patterns.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,7 @@
'use strict';
importScripts('puzzle-core.js?v=47.77-5');
importScripts('build-meta.js');
importScripts('puzzle-patterns.js');
importScripts(`puzzle-core.js?v=${BendBuildMeta.APP_VERSION}-${BendBuildMeta.GENERATOR_VERSION}`);
const uniquenessCache = new Map();
self.onmessage = ({data}) => {

View file

@ -5,10 +5,14 @@ const { URL } = require('url');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const MAX_MESSAGE_BYTES = 64 * 1024;
const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024;
const MAX_POLL_EVENTS = 256;
const MAX_VIEW_SPAN = 512;
const MAX_WORLD_COORD = 1_000_000_000;
const REACTION_EMOJIS = new Set(['👍','👉🏻','🙏','🧠','🎉']);
const REACTION_EMOJIS = new Set(['👍','🤩','🙏','🧠','🎉']);
const REACTION_STYLES = new Set(['classic','giant','laser','orbit','firework','comet']);
const REACTION_TTL_MS = 4500;
function reactionTtlForStyle(style) { return style==='comet'?3000:style==='firework'?3400:style==='orbit'?3200:style==='giant'||style==='laser'?2700:1050; }
const MAX_REACTIONS = 1024;
const REACTION_MIN_INTERVAL_MS = 450;
const SUBSCRIPTION_BUCKET_SIZE = 64;
@ -58,19 +62,30 @@ function encodeFrame(opcode, payload = Buffer.alloc(0)) {
return Buffer.concat([header, payload]);
}
function sendJson(client, value) {
if (!client || client.closed || !client.socket.writable) return false;
try { client.socket.write(encodeFrame(1, Buffer.from(JSON.stringify(value)))); return true; }
if (!client || client.closed) return false;
if (client.transport === 'poll') {
const sequence = ++client.eventSequence;
client.events.push({sequence, message:value});
if (client.events.length > MAX_POLL_EVENTS) {const removed=client.events.splice(0, client.events.length - MAX_POLL_EVENTS);client.droppedThroughSequence=Math.max(client.droppedThroughSequence||0,...removed.map(event=>event.sequence))}
client.wakePoll?.();
return true;
}
if (!client.socket?.writable) return false;
if(client.backpressured)return false;
if((client.socket.writableLength||0)>MAX_SOCKET_BUFFER_BYTES){client.closed=true;client.socket.destroy();return false}
try {const accepted=client.socket.write(encodeFrame(1, Buffer.from(JSON.stringify(value))));if(!accepted){client.backpressured=true;client.socket.once('drain',()=>{client.backpressured=false})}return accepted;}
catch (_) { return false; }
}
function sendClose(client, code = 1000, reason = '') {
if (!client || client.closed) return;
client.closed = true;
if (client.transport === 'poll') return;
const text = Buffer.from(String(reason).slice(0, 100));
const payload = Buffer.allocUnsafe(2 + text.length); payload.writeUInt16BE(code, 0); text.copy(payload, 2);
try { client.socket.end(encodeFrame(8, payload)); } catch (_) { client.socket.destroy(); }
}
function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/realtime', claimTtlMs = 5 * 60 * 1000, now = Date.now}) {
function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/realtime', claimTtlMs = 5 * 60 * 1000, now = Date.now, maxClients = 500, maxClientsPerPlayer = 5}) {
if (!server || typeof authenticate !== 'function' || typeof getBoardInfo !== 'function') throw new Error('Invalid realtime hub options');
const clients = new Map();
const claims = new Map();
@ -79,6 +94,7 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
let serial = Promise.resolve();
let nextConnection = 1;
let nextReaction = 1;
function playerClientCount(playerId){let count=0;for(const client of clients.values())if(!client.closed&&client.playerId===playerId)count++;return count}
function withSerial(task) {
const run = serial.then(task, task); serial = run.then(() => undefined, () => undefined); return run;
@ -91,13 +107,13 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
function registerViewport(client,viewport){unregisterViewport(client);client.viewport=viewport;client.viewportBucketKeys=viewportBucketKeys(viewport);for(const key of client.viewportBucketKeys){let set=viewportBuckets.get(key);if(!set)viewportBuckets.set(key,set=new Set());set.add(client)}}
function subscribersAt(x,y){const key=`${Math.floor(x/SUBSCRIPTION_BUCKET_SIZE)},${Math.floor(y/SUBSCRIPTION_BUCKET_SIZE)}`;return viewportBuckets.get(key)||new Set()}
function publicPlayer(client) {
return {presenceId: client.id, playerId: client.playerId, name: client.name, x: client.x, y: client.y, cursorStyle: client.cursorStyle || 'default', at: client.cursorAt || now()};
return {presenceId: client.id, playerId: client.playerId, name: client.name, x: client.x, y: client.y, vx:client.vx||0, vy:client.vy||0, cursorStyle: client.cursorStyle || 'default', sentAt:client.cursorAt || now(), at: client.cursorAt || now()};
}
function publicClaim(claim) {
return {boardId: claim.boardId, playerId: claim.ownerPlayerId, playerName: claim.ownerName, presenceId: claim.ownerPresenceId, expiresAt: claim.expiresAt, ...claim.bounds};
}
function claimActive(claim, timestamp = now()) { return Boolean(claim && claim.expiresAt > timestamp); }
function publicReaction(reaction) { return {id:reaction.id, playerId:reaction.playerId, playerName:reaction.playerName, emoji:reaction.emoji, x:reaction.x, y:reaction.y, createdAt:reaction.createdAt, expiresAt:reaction.expiresAt}; }
function publicReaction(reaction) { return {id:reaction.id, playerId:reaction.playerId, playerName:reaction.playerName, emoji:reaction.emoji, style:reaction.style, x:reaction.x, y:reaction.y, createdAt:reaction.createdAt, expiresAt:reaction.expiresAt}; }
function pruneReactions(timestamp = now()) {
for (const [id, reaction] of reactions) if (reaction.expiresAt <= timestamp) reactions.delete(id);
while (reactions.size > MAX_REACTIONS) reactions.delete(reactions.keys().next().value);
@ -142,25 +158,35 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
function releaseOtherClaimsForPlayer(playerId, keepBoardId) {
for (const [boardId, claim] of claims) if (boardId !== keepBoardId && claim.ownerPlayerId === playerId) releaseBoardClaim(boardId, 'moved');
}
async function requestClaim(client, message) {
const requestId = String(message.requestId || '').slice(0, 80), boardId = cleanBoardId(message.boardId);
if (!boardId) return sendJson(client, {type:'claim-result', requestId, ok:false, reason:'invalid-board', serverTime:now()});
await withSerial(async () => {
async function claimBoard(identity, boardIdValue, presenceId = null) {
const boardId = cleanBoardId(boardIdValue), timestamp = now();
if (!boardId) return {ok:false, reason:'invalid-board', serverTime:timestamp};
return withSerial(async () => {
pruneClaims();
const board = await getBoardInfo(boardId);
if (!board || board.solved) return sendJson(client, {type:'claim-result', requestId, ok:false, reason:board?.solved?'solved':'missing', serverTime:now()});
if (!board || board.solved) return {ok:false, reason:board?.solved?'solved':'missing', serverTime:now()};
const existing = claims.get(boardId);
if (claimActive(existing) && existing.ownerPlayerId !== client.playerId) return sendJson(client, {type:'claim-result', requestId, ok:false, reason:'occupied', claim:publicClaim(existing), serverTime:now()});
releaseOtherClaimsForPlayer(client.playerId, boardId);
const timestamp = now(), claim = {
boardId, ownerPlayerId:client.playerId, ownerName:client.name, ownerPresenceId:client.id,
expiresAt:timestamp + claimTtlMs, bounds:board.bounds
if (claimActive(existing) && existing.ownerPlayerId !== identity.playerId) {
return {ok:false, reason:'occupied', claim:publicClaim(existing), serverTime:now()};
}
releaseOtherClaimsForPlayer(identity.playerId, boardId);
const ownerClient = presenceId ? clients.get(String(presenceId)) : null;
const linkedClient = ownerClient && ownerClient.authenticated && ownerClient.playerId === identity.playerId ? ownerClient : null;
const claimedAt = now(), claim = {
boardId, ownerPlayerId:identity.playerId, ownerName:identity.name,
ownerPresenceId:linkedClient?.id || null, expiresAt:claimedAt + claimTtlMs, bounds:board.bounds
};
claims.set(boardId, claim); client.currentClaimBoardId = boardId;
sendJson(client, {type:'claim-result', requestId, ok:true, claim:publicClaim(claim), serverTime:timestamp});
claims.set(boardId, claim);
if (linkedClient) linkedClient.currentClaimBoardId = boardId;
broadcastClaim(claim);
return {ok:true, claim:publicClaim(claim), serverTime:claimedAt};
});
}
async function requestClaim(client, message) {
const requestId = String(message.requestId || '').slice(0, 80);
const result = await claimBoard({playerId:client.playerId, name:client.name}, message.boardId, client.id);
sendJson(client, {type:'claim-result', requestId, ...result});
}
function touchClaim(client, message) {
const boardId = cleanBoardId(message.boardId), claim = boardId && claims.get(boardId);
if (!claimActive(claim) || claim.ownerPlayerId !== client.playerId) return false;
@ -174,21 +200,25 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
return releaseBoardClaim(boardId, 'released');
}
function publishReaction(client, message) {
const emoji=String(message.emoji||''),x=finite(message.x,NaN),y=finite(message.y,NaN),timestamp=now();
const emoji=String(message.emoji||''),style=REACTION_STYLES.has(message.style)?message.style:'classic',x=finite(message.x,NaN),y=finite(message.y,NaN),timestamp=now();
if(!REACTION_EMOJIS.has(emoji)||!Number.isFinite(x)||!Number.isFinite(y)||Math.abs(x)>MAX_WORLD_COORD||Math.abs(y)>MAX_WORLD_COORD)return false;
if(timestamp-client.lastReactionAt<REACTION_MIN_INTERVAL_MS)return false;
client.lastReactionAt=timestamp;pruneReactions(timestamp);
pruneReactions(timestamp);
if(style!=='classic')for(const active of reactions.values())if(active.playerId===client.playerId&&active.style!=='classic'&&active.expiresAt>timestamp)return false;
client.lastReactionAt=timestamp;
const requested=String(message.id||'').replace(/[^a-zA-Z0-9_-]/g,'').slice(0,64),id=requested||`r${timestamp.toString(36)}-${nextReaction++}`;
const reaction={id,playerId:client.playerId,playerName:client.name,emoji,x,y,createdAt:timestamp,expiresAt:timestamp+REACTION_TTL_MS};reactions.set(id,reaction);pruneReactions(timestamp);
const reaction={id,playerId:client.playerId,playerName:client.name,emoji,style,x,y,createdAt:timestamp,expiresAt:timestamp+reactionTtlForStyle(style)};reactions.set(id,reaction);pruneReactions(timestamp);
const event={type:'reaction',reaction:publicReaction(reaction),serverTime:timestamp};for(const other of subscribersAt(x,y))if(other.authenticated&&pointInViewport(other.viewport,x,y))sendJson(other,event);return true;
}
async function handleMessage(client, raw) {
const timestamp=now();if(!client.messageWindowStartedAt||timestamp-client.messageWindowStartedAt>=10_000){client.messageWindowStartedAt=timestamp;client.messageCount=0}client.messageCount=(client.messageCount||0)+1;if(client.messageCount>300){sendClose(client,1013,'Message rate exceeded');removeClient(client);return false}
let message; try { message = JSON.parse(raw); } catch (_) { return sendJson(client, {type:'error', error:'invalid-json'}); }
if (!message || typeof message !== 'object' || Array.isArray(message)) return;
if (!client.authenticated) {
if (message.type !== 'hello') return sendClose(client, 1008, 'Authentication required');
try {
const identity = await authenticate({playerId:message.playerId, token:message.token});
if(playerClientCount(identity.playerId)>=maxClientsPerPlayer)return sendClose(client,1013,'Too many player connections');
client.authenticated = true; client.playerId = identity.playerId; client.name = identity.name;
clearTimeout(client.authTimer); client.authTimer = 0;
sendJson(client, {type:'ready', presenceId:client.id, playerId:client.playerId, name:client.name, claimTtlMs, serverTime:now()});
@ -200,7 +230,7 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
const x = finite(message.x, NaN), y = finite(message.y, NaN);
if (!Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > MAX_WORLD_COORD || Math.abs(y) > MAX_WORLD_COORD) return;
const timestamp = now(); if (timestamp - client.lastCursorMessageAt < 45) return;
client.lastCursorMessageAt = timestamp; client.x = x; client.y = y; client.cursorStyle = cleanCursorStyle(message.cursorStyle); client.cursorAt = timestamp; broadcastCursor(client); return;
client.lastCursorMessageAt = timestamp; client.x = x; client.y = y; client.vx=clamp(finite(message.vx,0),-2000,2000); client.vy=clamp(finite(message.vy,0),-2000,2000); client.cursorStyle = cleanCursorStyle(message.cursorStyle); client.cursorAt = timestamp; broadcastCursor(client); return;
}
if (message.type === 'cursor-hide') {
const x = client.x, y = client.y; client.x = NaN; client.y = NaN; client.cursorAt = now();
@ -245,18 +275,62 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
}
}
function removeClient(client) {
if (!client || client.removed) return; client.removed = true; clearTimeout(client.authTimer); unregisterViewport(client); clients.delete(client.id);
if (!client || client.removed) return; client.removed = true; client.closed = true; clearTimeout(client.authTimer);if(client.pollWaiter){clearTimeout(client.pollWaiter.timer);client.pollWaiter.resolve(null);client.pollWaiter=null} unregisterViewport(client); clients.delete(client.id);
for (const [boardId, claim] of claims) if (claim.ownerPresenceId === client.id) releaseBoardClaim(boardId, 'disconnected');
if (client.authenticated && Number.isFinite(client.x) && Number.isFinite(client.y)) broadcastAll({type:'player-left', presenceId:client.id, playerId:client.playerId, serverTime:now()}, other => pointInViewport(other.viewport, client.x, client.y));
}
function pollingClientFor(identity, presenceId) {
const client = clients.get(String(presenceId || ''));
if (!client || client.transport !== 'poll' || client.closed || client.playerId !== identity?.playerId) return null;
client.lastSeenAt = now();
return client;
}
function pollingEnvelope(client, afterSequence = 0) {
const after = Math.max(0, Math.floor(finite(afterSequence, 0)));
const events = client.events.filter(event => event.sequence > after);
const eventGap=after>client.eventSequence||after<(client.droppedThroughSequence||0);
const sequence = events.length ? events[events.length - 1].sequence : client.eventSequence || 0;
if (events.length) client.events = client.events.filter(event => event.sequence > sequence);
return {presenceId:client.id, sequence, eventGap, messages:events.map(event => event.message), serverTime:now()};
}
function createPollingClient(identity) {
if(clients.size>=maxClients||playerClientCount(identity.playerId)>=maxClientsPerPlayer)return null;
const timestamp = now();
const client = {id:`h${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'poll', socket:null, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:true, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0, vy:0,cursorStyle:'default',cursorAt:0,lastCursorMessageAt:0,lastPongAt:timestamp,lastSeenAt:timestamp,fragments:[],fragmentBytes:0,fragmentOpcode:0,currentClaimBoardId:null,lastReactionAt:0,authTimer:0,viewportBucketKeys:[],playerId:identity.playerId,name:identity.name,events:[],eventSequence:0,droppedThroughSequence:0,pollWaiter:null,wakePoll:null,messageWindowStartedAt:timestamp,messageCount:0};
clients.set(client.id, client);
sendJson(client, {type:'ready', presenceId:client.id, playerId:client.playerId, name:client.name, claimTtlMs, serverTime:timestamp});
return pollingEnvelope(client, 0);
}
async function handlePollingMessage(identity, presenceId, message, afterSequence = 0) {
const client = pollingClientFor(identity, presenceId);
if (!client) return null;
await handleMessage(client, JSON.stringify(message || {}));
return client.closed||client.removed?null:pollingEnvelope(client, afterSequence);
}
function pollPollingClient(identity, presenceId, afterSequence = 0, waitMs = 20_000) {
const client = pollingClientFor(identity, presenceId);
if(!client)return null;const after=Math.max(0,Math.floor(finite(afterSequence,0)));
if(client.events.some(event=>event.sequence>after)||after>client.eventSequence||after<(client.droppedThroughSequence||0))return pollingEnvelope(client,after);
if(client.pollWaiter){clearTimeout(client.pollWaiter.timer);client.pollWaiter.resolve(pollingEnvelope(client,client.pollWaiter.after));client.pollWaiter=null}
if(waitMs<=0)return pollingEnvelope(client,after);
return new Promise(resolve=>{const finish=()=>{if(client.pollWaiter?.resolve!==resolve)return;clearTimeout(client.pollWaiter.timer);client.pollWaiter=null;client.wakePoll=null;resolve(client.closed?null:pollingEnvelope(client,after))},timer=setTimeout(finish,Math.max(1000,Math.min(25_000,waitMs)));client.pollWaiter={resolve,after,timer};client.wakePoll=finish});
}
function disconnectPollingClient(identity, presenceId) {
const client = pollingClientFor(identity, presenceId);
if (!client) return false;
removeClient(client);
return true;
}
function handleUpgrade(req, socket, head) {
let url; try { url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); } catch (_) { socket.destroy(); return; }
if (url.pathname !== path) { socket.destroy(); return; }
if (url.pathname !== path && !url.pathname.endsWith(path)) { socket.destroy(); return; }
if(clients.size>=maxClients){socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');socket.destroy();return}
const key = req.headers['sec-websocket-key'], version = req.headers['sec-websocket-version'];
if (req.method !== 'GET' || typeof key !== 'string' || version !== '13') { socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); socket.destroy(); return; }
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
socket.setNoDelay(true);
const client = {id:`p${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, socket, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:false, closed:false, removed:false, viewport:null, x:NaN, y:NaN, cursorStyle:'default', cursorAt:0, lastCursorMessageAt:0, lastPongAt:now(), fragments:[], fragmentBytes:0, fragmentOpcode:0, currentClaimBoardId:null, lastReactionAt:0, authTimer:0, viewportBucketKeys:[]};
const timestamp=now(),client = {id:`p${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'websocket', socket, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:false, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0,vy:0,cursorStyle:'default',cursorAt:0,lastCursorMessageAt:0,lastPongAt:timestamp,lastSeenAt:timestamp,fragments:[],fragmentBytes:0,fragmentOpcode:0,currentClaimBoardId:null,lastReactionAt:0,authTimer:0,viewportBucketKeys:[],events:[],eventSequence:0,backpressured:false,messageWindowStartedAt:timestamp,messageCount:0};
clients.set(client.id, client);
client.authTimer = setTimeout(() => sendClose(client, 1008, 'Authentication timeout'), 5000);
socket.on('data', chunk => consumeFrames(client, chunk)); socket.on('error', () => removeClient(client)); socket.on('close', () => removeClient(client)); socket.on('end', () => removeClient(client));
@ -267,6 +341,10 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
const heartbeatTimer = setInterval(() => {
const timestamp = now();
for (const client of clients.values()) {
if (client.transport === 'poll') {
if (timestamp - client.lastSeenAt > 45_000) removeClient(client);
continue;
}
if (timestamp - client.lastPongAt > 90_000) { client.socket.destroy(); continue; }
try { client.socket.write(encodeFrame(9, Buffer.from(String(timestamp)))); } catch (_) { client.socket.destroy(); }
}
@ -277,7 +355,13 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
hasClaim(playerId, boardId) { pruneClaims(); const claim = claims.get(boardId); return claimActive(claim) && claim.ownerPlayerId === playerId; },
releaseBoardClaim,
broadcastClearEvents(events) { for (const event of events || []) broadcastAll({type:'board-cleared', event, serverTime:now()}); },
broadcastWorldRevision(revision, boardIds = [], page = null) { const cleanRevision=Math.max(0,Math.floor(finite(revision,0))),ids=[...new Set([...boardIds].map(cleanBoardId).filter(Boolean))],message={type:'world-revision',revision:cleanRevision,boardIds:ids,serverTime:now()};if(page&&typeof page==='object')message.page=page;broadcastAll(message); },
notifyProfileChange(playerId, name) { for (const client of clients.values()) if (client.playerId === playerId) client.name = name; broadcastAll({type:'player-profile', playerId, name, serverTime:now()}); },
createPollingClient,
handlePollingMessage,
pollPollingClient,
disconnectPollingClient,
claimBoard,
close() { clearInterval(cleanupTimer); clearInterval(heartbeatTimer); server.off('upgrade', handleUpgrade); for (const client of clients.values()) sendClose(client, 1001, 'Server shutdown'); clients.clear(); claims.clear(); reactions.clear(); },
_debug: {clients, claims, reactions, snapshotFor}
};

10
runtime-config.js Normal file
View file

@ -0,0 +1,10 @@
'use strict';
(function configureLinkFieldRuntime(root){
let appBaseUrl='',apiBridgeUrl='';
try{
const scriptUrl=root.document?.currentScript?.src||root.location?.href||'';
appBaseUrl=new URL('./',scriptUrl).href;
apiBridgeUrl=new URL('api-bridge.php',appBaseUrl).href;
}catch(_){appBaseUrl='';apiBridgeUrl=''}
root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,apiBridgeUrl,realtimeTransport:'auto'});
})(globalThis);

View file

@ -0,0 +1,28 @@
'use strict';
const fs=require('fs');
const path=require('path');
const root=path.resolve(__dirname,'..');
const governed=[
'archive-codec.js',
'build-meta.js',
'runtime-config.js',
'shared-contracts.js',
'client/input/frame-scheduler.js',
'client/input/drag.js',
'client/input/gesture-coordinator.js',
'client/input/interaction-state.js',
'client/ui/cursor.js',
'server/http-router.js',
'server/auth.js',
'server/json-repository.js',
'server/player-service.js'
];
const maximum=180,violations=[];
for(const relative of governed){
const file=path.join(root,relative),lines=fs.readFileSync(file,'utf8').split(/\r?\n/);
lines.forEach((line,index)=>{if(line.length>maximum)violations.push(`${relative}:${index+1} (${line.length})`)});
}
if(violations.length)throw new Error(`Handwritten source lines exceed ${maximum} columns:\n${violations.join('\n')}`);
console.log(`Source policy passed for ${governed.length} handwritten modules (${maximum}-column maximum)`);

View file

@ -0,0 +1,14 @@
'use strict';
const fs=require('fs');
const path=require('path');
const root=path.resolve(__dirname,'..');
const packageJson=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
const config=JSON.parse(fs.readFileSync(path.join(root,'build-config.json'),'utf8'));
if(!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(packageJson.version))throw new Error('package.json version must use major.minor.patch');
const appVersion=packageJson.version.split('.').slice(0,2).join('.');
for(const key of['SAVE_SCHEMA','STORAGE_SCHEMA','IDB_LAYOUT_VERSION','FIELD_STORAGE_FORMAT','GAMEPLAY_DATA_VERSION','GENERATOR_VERSION'])if(!Number.isSafeInteger(config[key])||config[key]<1)throw new Error(`Invalid ${key}`);
if(typeof config.WORLD_GENERATION!=='string'||!config.WORLD_GENERATION)throw new Error('Invalid WORLD_GENERATION');
const meta={APP_VERSION:appVersion,PACKAGE_VERSION:packageJson.version,...config};
const source=`'use strict';\n// Generated from package.json and build-config.json. Do not edit.\n(function attachBuildMeta(root,factory){\n const api=factory();\n if(typeof module==='object'&&module.exports)module.exports=api;\n if(root)root.BendBuildMeta=api;\n})(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze(${JSON.stringify(meta,null,2)}));\n`;
fs.writeFileSync(path.join(root,'build-meta.js'),source);

View file

@ -0,0 +1,28 @@
'use strict';
const fs=require('fs');
const path=require('path');
const root=path.resolve(__dirname,'..');
const sourcePath=path.join(root,'store-catalog.json');
const targetPath=path.join(root,'store-catalog.generated.js');
const catalog=JSON.parse(fs.readFileSync(sourcePath,'utf8'));
if(!Array.isArray(catalog)||catalog.length===0)throw new Error('Store catalog must be a non-empty array');
const ids=new Set();
for(const item of catalog){
if(!item||typeof item!=='object'||typeof item.id!=='string'||!/^[A-Za-z0-9:_-]+$/.test(item.id))throw new Error('Store catalog contains an invalid item ID');
if(ids.has(item.id))throw new Error(`Duplicate store item: ${item.id}`);
if(!Number.isSafeInteger(item.cost)||item.cost<=0)throw new Error(`Invalid cost for ${item.id}`);
if(item.cursorStyle!==null&&typeof item.cursorStyle!=='string')throw new Error(`Invalid cursorStyle for ${item.id}`);
if(typeof item.scoreLens!=='boolean')throw new Error(`Invalid scoreLens for ${item.id}`);
if(item.lineColor!=null&&!/^#[0-9a-f]{6}$/i.test(item.lineColor))throw new Error(`Invalid lineColor for ${item.id}`);
if(item.lineEffect!=null&&!/^[a-z][a-z0-9-]{0,31}$/.test(item.lineEffect))throw new Error(`Invalid lineEffect for ${item.id}`);
if(item.aurora!=null&&typeof item.aurora!=='boolean')throw new Error(`Invalid aurora flag for ${item.id}`);
if(item.aurora===true&&item.lineColor==null)throw new Error(`Aurora item must be a line color: ${item.id}`);
if(item.reactionStyle!=null&&!/^[a-z][a-z0-9-]{0,31}$/.test(item.reactionStyle))throw new Error(`Invalid reactionStyle for ${item.id}`);
if([item.lineColor,item.lineEffect,item.reactionStyle].filter(value=>value!=null).length>1)throw new Error(`Multiple cosmetic contracts for ${item.id}`);
ids.add(item.id);
}
const body=`'use strict';\n// Generated from store-catalog.json by scripts/generate-store-catalog.js. Do not edit.\nglobalThis.BendStoreCatalog=Object.freeze(${JSON.stringify(catalog)}.map(item=>Object.freeze(item)));\n`;
fs.writeFileSync(targetPath,body);

View file

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

View file

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

471
scripts/service-control.js Normal file
View file

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

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

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

View file

@ -0,0 +1,20 @@
'use strict';
const fs=require('fs');
const path=require('path');
const root=path.resolve(__dirname,'..');
const corePath=path.join(root,'puzzle-core.js');
const patternsPath=path.join(root,'puzzle-patterns.js');
const source=fs.readFileSync(corePath,'utf8');
const loader="const PuzzlePatterns=root.BendPuzzlePatterns||(typeof module==='object'&&module.exports?require('./puzzle-patterns'):null);\nif(!PuzzlePatterns)throw new Error('BendPuzzlePatterns is not loaded');\nconst MICRO_PATTERN_SETS=PuzzlePatterns.MICRO_PATTERN_SETS;\n";
if(source.includes("require('./puzzle-patterns')")){
if(!fs.existsSync(patternsPath))throw new Error('puzzle-core.js is split but puzzle-patterns.js is missing');
process.exit(0);
}
const start=source.indexOf('const MICRO_PATTERN_SETS='),end=source.indexOf('\nconst H_PORT_PROFILES=',start);
if(start<0||end<0)throw new Error('Could not locate the generated micro-pattern table');
const declaration=source.slice(start,end),expression=declaration.slice('const MICRO_PATTERN_SETS='.length,-1);
const patternModule=`'use strict';\n// Generated puzzle routing patterns, split from puzzle-core.js. Do not edit by hand.\n(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;if(root)root.BendPuzzlePatterns=api})(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze({MICRO_PATTERN_SETS:Object.freeze(${expression})}));\n`;
fs.writeFileSync(patternsPath,patternModule);
fs.writeFileSync(corePath,source.slice(0,start)+loader+source.slice(end+1));

607
server.js
View file

@ -7,37 +7,116 @@ const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const { createRealtimeHub } = require('./realtime-server');
const { createHttpRouter } = require('./server/http-router');
const { createAuthenticator } = require('./server/auth');
const { createJsonRepository } = require('./server/json-repository');
const { installApacheBridge } = require('./server/apache-bridge');
const { createPlayerService } = require('./server/player-service');
const { createWorldBackup } = require('./server/world-backup');
const BuildMeta = require('./build-meta');
const SharedContracts = require('./shared-contracts');
const AppLogic = require('./app-logic');
const PuzzleCore = require('./puzzle-core');
const STORE_CATALOG = new Map(require('./store-catalog.json').map(item => [item.id, Object.freeze(item)]));
const STARTER_LINE_COLOR_IDS=Object.freeze([...STORE_CATALOG.values()].filter(item=>item.lineColor&&!item.aurora&&item.cost===5000).map(item=>item.id));
const ROOT = __dirname;
const DATA_DIR = path.resolve(process.env.BEND_FIELD_DATA_DIR || path.join(ROOT, 'cloud-data'));
const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT);
const PRODUCTION_DATA_DIR = path.resolve(String(process.env.LINK_FIELD_WORLD_DIR || '/link-field/world'));
const TEST_DATA_ROOT = String(process.env.LINK_FIELD_TEST_DATA_ROOT || '').trim();
const DATA_DIR = TEST_DATA_ROOT ? path.join(path.resolve(TEST_DATA_ROOT), 'world') : PRODUCTION_DATA_DIR;
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(DATA_DIR),'backups')));
const BACKUPS_ENABLED=!TEST_DATA_ROOT&&!['0','false','off','no'].includes(String(process.env.LINK_FIELD_BACKUPS||'').trim().toLowerCase());
const INSTANCE_LOCK_FILE = path.join(DATA_DIR, 'server.pid');
const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json');
const WORLD_COMMIT_FILE = path.join(DATA_DIR, 'shared-world.commit.json');
const WORLD_BOARDS_DIR = path.join(DATA_DIR, 'shared-world.boards');
const HOST = process.env.HOST || '0.0.0.0';
const PORT = Number(process.env.PORT || 8080);
const MAX_BODY_BYTES = 64 * 1024 * 1024;
const MAX_BOARDS_PER_PUSH = 10000;
const MAX_PATHS_PER_BOARD = 512;
const MAX_CELLS_PER_PATH = 5000;
const CLOUD_PAGE_LIMIT = 512;
const DEFAULT_PORT = 8080;
const FALLBACK_PORT_START = 3000;
const FALLBACK_PORT_COUNT = 20;
function commandLineOption(name) {
const direct = process.argv.find(argument => argument.startsWith(`${name}=`));
if (direct) return direct.slice(name.length + 1);
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function parsePort(value, fallback = DEFAULT_PORT) {
if (value == null || String(value).trim() === '') return fallback;
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid server port: ${value}`);
return port;
}
function uniquePorts(values) {
return [...new Set(values.filter(port => Number.isSafeInteger(port) && port >= 0 && port <= 65535))];
}
function defaultPortCandidates(preferredPort = DEFAULT_PORT) {
const fallbackPorts = Array.from({length:FALLBACK_PORT_COUNT}, (_, index) => FALLBACK_PORT_START + index);
return uniquePorts([preferredPort, ...fallbackPorts, 0]);
}
const CLI_HOST = commandLineOption('--host');
const CLI_PORT = commandLineOption('--port');
const HOST = CLI_HOST || process.env.HOST || '127.0.0.1';
const PORT_SOURCE = CLI_PORT ?? process.env.LINK_FIELD_PORT ?? process.env.PORT;
const PORT_EXPLICIT = PORT_SOURCE != null && String(PORT_SOURCE).trim() !== '';
const PORT_STRICT = process.argv.includes('--strict-port') || ['1','true','on','yes'].includes(String(process.env.LINK_FIELD_STRICT_PORT || '').trim().toLowerCase());
const PORT = parsePort(PORT_SOURCE, DEFAULT_PORT);
const APACHE_BRIDGE_SETTING=String(process.env.LINK_FIELD_APACHE_BRIDGE||'').trim().toLowerCase();
const APACHE_BRIDGE_ENABLED = TEST_DATA_ROOT ? ['1','true','on','yes'].includes(APACHE_BRIDGE_SETTING) : !['0','false','off','no'].includes(APACHE_BRIDGE_SETTING);
const PUBLIC_BRIDGE_PORT_FILE = TEST_DATA_ROOT&&!process.env.LINK_FIELD_PUBLIC_DIR?path.join(path.resolve(TEST_DATA_ROOT),'.linkfield-port'):path.join(PUBLIC_ROOT, '.linkfield-port');
const MAX_BODY_BYTES = 8 * 1024 * 1024;
const MAX_BOARDS_PER_PUSH = 16;
const MAX_METAS_PER_PUSH = 8;
const MAX_PATHS_PER_BOARD = 128;
const MAX_CELLS_PER_PATH = 1250;
const CLOUD_PAGE_LIMIT = 64;
const CLOUD_READ_CONCURRENCY = 16;
const CHANGE_HISTORY_LIMIT = 256;
const CLEAR_EVENT_LIMIT = 64;
const MAX_PLAYER_PURCHASES = 10000;
const MAX_PLAYER_RECORDS = 100000;
const GENERATION_FAILURE_BONUS = 2500;
const GENERATION_FAILURE_MIN_DELAY_MS = 8000;
const PLAYER_RE = /^[a-f0-9]{16,64}$/i;
const TOKEN_RE = /^[a-f0-9]{32,128}$/i;
const BOARD_RE = /^B(?:0|[1-9][0-9]*)$/;
const MUTATION_RE=/^[A-Za-z0-9_-]{8,96}$/;
const BOARD_RE = SharedContracts.BOARD_ID_RE;
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.ttf': 'font/ttf', '.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
};
const PUBLIC_STATIC_ROOTS=new Set(['index.html','style.css','favicon.svg','favicon.ico','build-meta.js','runtime-config.js','shared-contracts.js','store-catalog.generated.js','store-catalog.json','puzzle-patterns.js','puzzle-core.js','app-logic.js','archive-codec.js','field-persistence.js','field-persistence-worker.js','puzzle-worker.js','app.js','assets','client']);
let worldQueue = Promise.resolve();
let worldCache=null;
const playerQueues = new Map();
let playerCapacityQueue=Promise.resolve();
let realtimeHub = null;
let playerRecordCount = null;
let instanceLockToken=null,instanceLockHeartbeatTimer=null;
const jsonRepository = createJsonRepository({fsp,crypto});
const rateWindows = new Map();
function rateLimit(key,limit,windowMs,cost=1){
const now=serverTime(),normalized=String(key||'unknown'),current=rateWindows.get(normalized);
if(rateWindows.size>10000){for(const[item,value]of rateWindows)if(value.resetAt<=now)rateWindows.delete(item);if(rateWindows.size>20000)throw Object.assign(new Error('Request limiter capacity has been reached'),{status:503})}
cost=Math.max(1,Math.floor(Number(cost)||1));
if(!current||current.resetAt<=now){if(cost<=limit){rateWindows.set(normalized,{count:cost,resetAt:now+windowMs});return true}const error=Object.assign(new Error('Too many requests'),{status:429,retryAfter:Math.max(1,Math.ceil(windowMs/1000))});throw error}
current.count+=cost;
if(current.count<=limit)return true;
const error=Object.assign(new Error('Too many requests'),{status:429,retryAfter:Math.max(1,Math.ceil((current.resetAt-now)/1000))});
throw error;
}
function requestIp(req){
const direct=String(req?.socket?.remoteAddress||'unknown').replace(/^::ffff:/,'');
if(!['127.0.0.1','::1'].includes(direct))return direct;
const forwarded=String(req?.headers?.['x-forwarded-for']||'').split(',').at(-1).trim();
return /^[0-9a-f:.]{3,64}$/i.test(forwarded)?forwarded:direct;
}
async function assertPlayerCapacity(){
if(playerRecordCount==null){let names=[];try{names=await fsp.readdir(DATA_DIR)}catch(error){if(error.code!=='ENOENT')throw error}playerRecordCount=names.filter(name=>/^[a-f0-9]{16,64}\.json$/i.test(name)).length}
if(playerRecordCount>=MAX_PLAYER_RECORDS)throw Object.assign(new Error('Player capacity has been reached'),{status:503});
}
function withPlayerCapacity(task){const run=playerCapacityQueue.then(task,task);playerCapacityQueue=run.then(()=>undefined,()=>undefined);return run}
function json(res, status, value) {
const body = JSON.stringify(value);
@ -50,38 +129,95 @@ function safeEqualHex(a,b){if(typeof a!=='string'||typeof b!=='string'||a.length
function badRequest(message){throw Object.assign(new Error(message),{status:400})}
function finiteNumber(value,fallback=0){return Number.isFinite(Number(value))?Number(value):fallback}
function cleanPlayerName(value,fallback=''){
const name=String(value||'').replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);
return name||fallback;
return SharedContracts.cleanPlayerName(value,fallback);
}
function defaultPlayerName(playerId){return `旅人-${String(playerId).slice(0,4).toUpperCase()}`}
function cleanId(value,maxLength=64){const text=String(value||'').trim();if(!text||text.length>maxLength||!/^[A-Za-z0-9:_-]+$/.test(text))return'';return text}
function cleanId(value,maxLength=64){return SharedContracts.cleanContractId(value,maxLength)}
function storeItem(itemId){return STORE_CATALOG.get(String(itemId||''))||null}
function normalizePlayerPurchases(raw){
const purchases=[],seenIds=new Set(),seenStoreItems=new Set();
for(const source of Array.isArray(raw)?raw:[]){const purchaseId=cleanId(source?.purchaseId,64),boardId=cleanId(source?.boardId,32),item=storeItem(source?.itemId),storeKey=`${boardId}:${item?.id||''}`;if(!purchaseId||!boardId||!item||seenIds.has(purchaseId)||seenStoreItems.has(storeKey))continue;const boughtAt=finiteNumber(source.boughtAt,0);seenIds.add(purchaseId);seenStoreItems.add(storeKey);purchases.push({purchaseId,boardId,itemId:item.id,buyer:cleanPlayerName(source.buyer),boughtAt:boughtAt>0?boughtAt:0,paidCost:Number.isSafeInteger(source.paidCost)&&source.paidCost>0?source.paidCost:item.cost})}
return purchases;
return SharedContracts.normalizePlayerPurchases(raw,{resolveItem:storeItem,maxPurchases:MAX_PLAYER_PURCHASES});
}
function normalizeGenerationBonuses(raw){return[...new Set((Array.isArray(raw)?raw:[]).map(value=>String(value||'')).filter(value=>BOARD_RE.test(value)))].slice(-10000)}
function playerSpentScore(record){return normalizePlayerPurchases(record?.purchases).reduce((sum,purchase)=>sum+Math.max(0,Number(purchase.paidCost)||0),0)}
function playerEarnedScore(record){return Number.isSafeInteger(record?.earnedScore)&&record.earnedScore>=0?record.earnedScore:0}
function publicPlayerState(record){const earnedScore=playerEarnedScore(record),spentScore=playerSpentScore(record);return{revision:Number.isSafeInteger(record?.economyRevision)?record.economyRevision:0,purchases:normalizePlayerPurchases(record?.purchases),earnedScore,spentScore,availableScore:Math.max(0,earnedScore-spentScore),updatedAt:finiteNumber(record?.updatedAt,0)}}
function starterLineColorForPlayer(playerId){const digest=crypto.createHash('sha256').update(`bend-field-line-color:${String(playerId||'')}`).digest();return STARTER_LINE_COLOR_IDS[digest.readUInt32BE(0)%STARTER_LINE_COLOR_IDS.length]}
function normalizeStarterLineColor(value,playerId){return STARTER_LINE_COLOR_IDS.includes(value)?value:starterLineColorForPlayer(playerId)}
function publicPlayerState(record){const earnedScore=playerEarnedScore(record),spentScore=playerSpentScore(record);return{revision:Number.isSafeInteger(record?.economyRevision)?record.economyRevision:0,purchases:normalizePlayerPurchases(record?.purchases),starterLineColor:normalizeStarterLineColor(record?.starterLineColor,record?.playerId),earnedScore,spentScore,availableScore:Math.max(0,earnedScore-spentScore),updatedAt:finiteNumber(record?.updatedAt,0)}}
function publicCloudPlayer(record){return{id:record.playerId,name:record.name,...publicPlayerState(record)}}
function withPlayerQueue(playerId,task){const previous=playerQueues.get(playerId)||Promise.resolve(),run=previous.then(task,task),tail=run.then(()=>undefined,()=>undefined);playerQueues.set(playerId,tail);return run.finally(()=>{if(playerQueues.get(playerId)===tail)playerQueues.delete(playerId)})}
function playerPath(playerId){if(!PLAYER_RE.test(playerId))badRequest('Invalid player id');return path.join(DATA_DIR,`${playerId.toLowerCase()}.json`)}
function worldBoardVersionPath(boardId,revision){if(!BOARD_RE.test(boardId)||!Number.isSafeInteger(revision)||revision<0)badRequest('Invalid board version');return path.join(WORLD_BOARDS_DIR,`${boardId}.${revision}.json`)}
async function atomicWriteJson(file,value){const tmp=`${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;await fsp.writeFile(tmp,JSON.stringify(value),{encoding:'utf8',mode:0o600});await fsp.rename(tmp,file)}
async function atomicWriteJson(file,value){return jsonRepository.write(file,value)}
async function readPlayer(playerId){
try{const value=JSON.parse(await fsp.readFile(playerPath(playerId),'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid player record');value.name=cleanPlayerName(value.name,defaultPlayerName(playerId));value.purchases=normalizePlayerPurchases(value.purchases);value.generationBonuses=normalizeGenerationBonuses(value.generationBonuses);value.economyRevision=Number.isSafeInteger(value.economyRevision)?value.economyRevision:0;value.earnedScore=playerEarnedScore(value);return value}
try{const value=JSON.parse(await fsp.readFile(playerPath(playerId),'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid player record');value.name=cleanPlayerName(value.name,defaultPlayerName(playerId));value.purchases=normalizePlayerPurchases(value.purchases);value.generationBonuses=normalizeGenerationBonuses(value.generationBonuses);value.economyRevision=Number.isSafeInteger(value.economyRevision)?value.economyRevision:0;value.earnedScore=playerEarnedScore(value);value.starterLineColor=normalizeStarterLineColor(value.starterLineColor,playerId);return value}
catch(error){if(error.code==='ENOENT')throw Object.assign(new Error('Cloud profile not found'),{status:404});throw error}
}
function emptyWorld(){const now=serverTime();return{revision:0,rowRevision:now*1000,expansionGrants:{},global:{schema:31,appVersion:'47.77',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},changes:[],clearEvents:[],createdAt:now,updatedAt:now}}
function emptyWorld(){const now=serverTime();return{revision:0,rowRevision:now*1000,expansionGrants:{},recentMutations:[],global:{schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},occupancy:{},changes:[],clearEvents:[],clearEventsDroppedThrough:0,createdAt:now,updatedAt:now}}
async function readWorld(){
try{const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};return value}
catch(error){if(error.code==='ENOENT')return emptyWorld();throw error}
if(worldCache)return worldCache;
try{
const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');
if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)throw Object.assign(new Error(`Stored world generation ${String(value.global?.worldGeneration||'missing')} does not match ${BuildMeta.WORLD_GENERATION}; restore or migrate the world before startup.`),{status:503,code:'EWORLDGENERATION'});
value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.recentMutations=Array.isArray(value.recentMutations)?value.recentMutations.slice(-1024):[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.clearEventsDroppedThrough=Number.isSafeInteger(value.clearEventsDroppedThrough)?value.clearEventsDroppedThrough:0;value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};worldCache=value;return value
}catch(error){if(error.code==='ENOENT'){worldCache=emptyWorld();return worldCache}throw error}
}
async function readWorldBoard(record,id){const revision=record.boardVersions[id];if(!Number.isSafeInteger(revision))return null;try{const value=JSON.parse(await fsp.readFile(worldBoardVersionPath(id,revision),'utf8'));return value&&typeof value==='object'?value:null}catch(error){if(error.code==='ENOENT')throw Object.assign(new Error(`Shared board is missing: ${id}`),{status:500});throw error}}
function authenticate(req){const raw=String(req.headers.authorization||''),match=/^Bearer\s+([^.]*)\.([^.]*)$/i.exec(raw);if(!match||!PLAYER_RE.test(match[1])||!TOKEN_RE.test(match[2]))throw Object.assign(new Error('Unauthorized'),{status:401});return{playerId:match[1].toLowerCase(),token:match[2].toLowerCase()}}
async function authenticatedPlayer(req){const auth=authenticate(req),record=await readPlayer(auth.playerId);if(!safeEqualHex(record.tokenHash,tokenHash(auth.token)))throw Object.assign(new Error('Invalid cloud sync code'),{status:403});return{...auth,record}}
async function readJsonBody(req){const chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
async function ensureWorldIndexes(record){
if(record.occupancy&&typeof record.occupancy==='object'&&!Array.isArray(record.occupancy)&&Number.isSafeInteger(record.global?.solved)&&Number.isSafeInteger(record.global?.earnedScore))return record;
const occupancy={};let solved=0,earnedScore=0;
for(const id of Object.keys(record.boardVersions)){const row=await readWorldBoard(record,id);if(!row?.meta)continue;for(const[dx,dy]of row.meta.chunks||[]){const key=`${row.meta.x+dx},${row.meta.y+dy}`;if(occupancy[key]&&occupancy[key]!==id)throw new Error(`Stored board overlap: ${occupancy[key]} / ${id}`);occupancy[key]=id}if(row.state?.solved){solved++;const award=Number(row.state.scoreAwarded)||0;if(Number.isSafeInteger(award)&&award>0)earnedScore+=award}}
record.occupancy=occupancy;record.global={...(record.global||{}),solved,earnedScore:Math.max(0,earnedScore)};return record;
}
function addMetaToWorldOccupancy(meta,occupancy,{requireTouch=false}={}){
let touches=!requireTouch;const keys=[];
for(const[dx,dy]of meta.chunks||[]){const x=meta.x+dx,y=meta.y+dy,key=`${x},${y}`;if(occupancy[key]&&occupancy[key]!==meta.id)badRequest(`Board overlap: ${occupancy[key]} / ${meta.id}`);keys.push(key);for(const[ox,oy]of[[1,0],[-1,0],[0,1],[0,-1]])if(occupancy[`${x+ox},${y+oy}`]&&occupancy[`${x+ox},${y+oy}`]!==meta.id)touches=true}
if(!touches)badRequest(`Generated board is not adjacent: ${meta.id}`);for(const key of keys)occupancy[key]=meta.id;return true;
}
async function collectRetiredBoardVersions(){
let world;try{world=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'))}catch(error){if(error.code==='ENOENT')return 0;throw error}
if(!world||world.global?.worldGeneration!==BuildMeta.WORLD_GENERATION||!world.boardVersions||typeof world.boardVersions!=='object')return 0;
let names=[];try{names=await fsp.readdir(WORLD_BOARDS_DIR)}catch(error){if(error.code==='ENOENT')return 0;throw error}
let removed=0;
for(const name of names){
const match=/^(B(?:0|[1-9][0-9]*))\.([0-9]+)\.json$/.exec(name);if(!match)continue;
const revision=Number(match[2]);if(world.boardVersions[match[1]]===revision)continue;
await fsp.unlink(path.join(WORLD_BOARDS_DIR,name)).catch(error=>{if(error.code!=='ENOENT')throw error});removed++;
}
return removed;
}
async function removeRetiredBoardVersions(entries){
for(const entry of entries||[]){
if(!entry||!BOARD_RE.test(entry.id)||!Number.isSafeInteger(entry.revision)||entry.revision<0)continue;
await fsp.unlink(worldBoardVersionPath(entry.id,entry.revision)).catch(error=>{if(error.code!=='ENOENT')console.warn(`Retired board cleanup warning: ${error.message}`)});
}
}
async function recoverPendingWorldCommit(){
let commit;try{commit=JSON.parse(await fsp.readFile(WORLD_COMMIT_FILE,'utf8'))}catch(error){if(error.code==='ENOENT')return false;throw error}
if(!commit||!Number.isSafeInteger(commit.revision)||!commit.world||commit.world.revision!==commit.revision)throw new Error('Invalid pending shared-world commit');
const current=await readWorld();
if(current.revision<commit.revision){
let prepared=true;
for(const id of commit.changedBoardIds||[]){const revision=commit.world.boardVersions?.[id];if(!Number.isSafeInteger(revision)){prepared=false;break}try{await fsp.access(worldBoardVersionPath(id,revision))}catch{prepared=false;break}}
if(!prepared){
if(commit.previousPlayer?.playerId)await writePlayerRecord(commit.previousPlayer);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return false;
}
if(commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await atomicWriteJson(WORLD_FILE,commit.world);worldCache=commit.world;
}else if(current.revision===commit.revision&&commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return true;
}
async function commitWorldMutation(world,{nextPlayer=null,previousPlayer=null,changedBoardIds=[]}={}){
const commit={revision:world.revision,world,nextPlayer,previousPlayer,changedBoardIds:[...changedBoardIds],preparedAt:serverTime()};
await atomicWriteJson(WORLD_COMMIT_FILE,commit);
if(nextPlayer)await writePlayerRecord(nextPlayer);
await atomicWriteJson(WORLD_FILE,world);worldCache=world;
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});
}
const authenticator=createAuthenticator({playerPattern:PLAYER_RE,tokenPattern:TOKEN_RE,readPlayer,hashToken:tokenHash,safeEqual:safeEqualHex});
function authenticate(req){return authenticator.parse(req)}
async function authenticatedPlayer(req){return authenticator.player(req)}
async function readJsonBody(req){const declared=Number(req.headers?.['content-length']);if(Number.isFinite(declared)&&declared>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});const chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES){req.destroy();throw Object.assign(new Error('Request body is too large'),{status:413})}chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
function cellKey(cell){return `${cell[0]},${cell[1]}`}
function normalizedRouteKey(cells){const forward=cells.map(cellKey).join('|'),reverse=[...cells].reverse().map(cellKey).join('|');return forward<reverse?forward:reverse}
function validatePuzzle(meta){
@ -98,40 +234,78 @@ function validatePuzzle(meta){
const validateRoute=(route,label)=>{if(!route||typeof route!=='object'||!Array.isArray(route.cells)||!route.cells.length||!Number.isInteger(route.startGate)||!Number.isInteger(route.endGate)||route.startGate<0||route.endGate<0||route.startGate>=gates.length||route.endGate>=gates.length)badRequest(`Invalid ${label} for ${meta.id}`);if(cellKey(route.cells[0])!==`${gates[route.startGate][0]},${gates[route.startGate][1]}`||cellKey(route.cells[route.cells.length-1])!==`${gates[route.endGate][0]},${gates[route.endGate][1]}`)badRequest(`Gate mismatch in ${label} for ${meta.id}`);const local=new Set();for(let index=0;index<route.cells.length;index++){const cell=route.cells[index],key=Array.isArray(cell)?cellKey(cell):'';if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||!valid.has(key)||local.has(key))badRequest(`Invalid ${label} cell for ${meta.id}`);local.add(key);if(index){const previous=route.cells[index-1],distance=Math.abs(previous[0]-cell[0])+Math.abs(previous[1]-cell[1]);if(distance!==1&&warpMap.get(cellKey(previous))!==key)badRequest(`Disconnected ${label} for ${meta.id}`)}}return route.cells};
for(const route of puzzle.solution)for(const cell of validateRoute(route,'solution path')){const key=cellKey(cell),count=(coverage.get(key)||0)+1;if(count>(crossingSet.has(key)?2:1))badRequest(`Overlapping solution for ${meta.id}`);coverage.set(key,count)}
if(coverage.size!==valid.size)badRequest(`Incomplete solution for ${meta.id}`);
return{valid,gates,warpMap,crossingSet,solutionKeys:puzzle.solution.map(route=>normalizedRouteKey(route.cells)).sort()};
return{valid,gates,clues:puzzle.n.map(clue=>[...clue]),warpMap,warps:(puzzle.specialCells?.warps||[]).map(pair=>({a:[...pair.a],b:[...pair.b]})),locks:(puzzle.specialCells?.locks||[]).map(lock=>({key:[...lock.key],door:[...lock.door]})),internalGateIndexes:(puzzle.specialCells?.internalGates||[]).flatMap(pair=>[pair?.a,pair?.b]).filter(Number.isInteger),crossingSet,solutionKeys:puzzle.solution.map(route=>normalizedRouteKey(route.cells)).sort()};
}
function sameCellValue(a,b){return Boolean(a&&b&&a[0]===b[0]&&a[1]===b[1])}
function gateOutsidePoint(gate,index,internalGateIndexes){if(internalGateIndexes.includes(index))return null;const delta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]}[gate?.[2]]||[0,0];return[(gate?.[0]||0)+delta[0],(gate?.[1]||0)+delta[1]]}
function pathAxisAtSolvedCell(path,puzzle,cell){const index=path.cells.findIndex(candidate=>sameCellValue(candidate,cell));if(index<0)return null;const previous=index?path.cells[index-1]:gateOutsidePoint(puzzle.gates[path.startGate],path.startGate,puzzle.internalGateIndexes),next=index<path.cells.length-1?path.cells[index+1]:gateOutsidePoint(puzzle.gates[path.endGate],path.endGate,puzzle.internalGateIndexes);if(!previous||!next||puzzle.warpMap.get(cellKey(previous))===cellKey(cell)||puzzle.warpMap.get(cellKey(cell))===cellKey(next))return null;if(previous[0]===cell[0]&&next[0]===cell[0])return'H';if(previous[1]===cell[1]&&next[1]===cell[1])return'V';return null}
function solvedStateMatchesPuzzle(state,puzzle){
const paths=Array.isArray(state.paths)?state.paths:[];if(paths.length!==puzzle.clues.length||paths.some(path=>path.endGate==null||path.detachedStart===true))return false;
const usedGates=new Set(),coverage=new Map();
for(const path of paths){
if(usedGates.has(path.startGate)||usedGates.has(path.endGate))return false;usedGates.add(path.startGate);usedGates.add(path.endGate);
if(!sameCellValue(path.cells[0],puzzle.gates[path.startGate])||!sameCellValue(path.cells[path.cells.length-1],puzzle.gates[path.endGate]))return false;
for(const cell of path.cells){const key=cellKey(cell),count=(coverage.get(key)||0)+1;if(count>(puzzle.crossingSet.has(key)?2:1))return false;coverage.set(key,count)}
for(const pair of puzzle.warps){const ai=path.cells.findIndex(cell=>sameCellValue(cell,pair.a)),bi=path.cells.findIndex(cell=>sameCellValue(cell,pair.b));if((ai>=0)!==(bi>=0)||ai>=0&&Math.abs(ai-bi)!==1)return false}
for(const lock of puzzle.locks){const keyIndex=path.cells.findIndex(cell=>sameCellValue(cell,lock.key)),doorIndex=path.cells.findIndex(cell=>sameCellValue(cell,lock.door));if(doorIndex>=0&&(keyIndex<0||keyIndex>doorIndex))return false}
const clues=puzzle.clues.filter(clue=>path.cells.some(cell=>cell[0]===clue[0]&&cell[1]===clue[1]));if(clues.length!==1)return false;
const analysis=AppLogic.analyzePathTurns(path,puzzle.gates,puzzle.warps,true,puzzle.internalGateIndexes),clue=clues[0];if(analysis.count!==clue[2]||!analysis.cells.some(cell=>cell[0]===clue[0]&&cell[1]===clue[1]))return false;
}
if(usedGates.size!==puzzle.gates.length||coverage.size!==puzzle.valid.size)return false;
for(const key of puzzle.crossingSet){if(coverage.get(key)!==2)return false;const cell=key.split(',').map(Number),axes=paths.filter(path=>path.cells.some(candidate=>sameCellValue(candidate,cell))).map(path=>pathAxisAtSolvedCell(path,puzzle,cell)).filter(Boolean);if(axes.length!==2||axes[0]===axes[1])return false}
return true;
}
function validateMeta(meta){
if(!meta||typeof meta!=='object'||!BOARD_RE.test(meta.id))badRequest('Invalid board metadata');const chunks=Array.isArray(meta.chunks)?meta.chunks:[];
if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||Math.abs(cell[0])>1000000||Math.abs(cell[1])>1000000||seen.has(cellKey(cell)))badRequest(`Invalid chunk coordinate for ${meta.id}`);seen.add(cellKey(cell))}
if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||cell[0]<0||cell[1]<0||cell[0]>64||cell[1]>64||seen.has(cellKey(cell)))badRequest(`Invalid chunk coordinate for ${meta.id}`);seen.add(cellKey(cell))}
if(Math.min(...chunks.map(cell=>cell[0]))!==0||Math.min(...chunks.map(cell=>cell[1]))!==0)badRequest(`Unanchored chunks for ${meta.id}`);const reached=new Set([cellKey(chunks[0])]),queue=[chunks[0]];while(queue.length){const[x,y]=queue.shift();for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const key=`${x+dx},${y+dy}`;if(seen.has(key)&&!reached.has(key)){reached.add(key);queue.push([x+dx,y+dy])}}}if(reached.size!==seen.size)badRequest(`Disconnected chunks for ${meta.id}`);
if(!Number.isSafeInteger(meta.x)||!Number.isSafeInteger(meta.y)||Math.abs(meta.x)>100000000||Math.abs(meta.y)>100000000)badRequest(`Invalid board coordinate for ${meta.id}`);
if(!Number.isSafeInteger(meta.seed)||meta.seed<0||meta.seed>0xffffffff)badRequest(`Invalid seed for ${meta.id}`);
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);const sectionRange=AppLogic.sectionCountRange(meta.targetLevel);if(chunks.length<sectionRange.min||chunks.length>sectionRange.max||meta.level<=3&&chunks.length!==1)badRequest(`Invalid section count for ${meta.id}`);
if(meta.entrySide!=null&&!['N','S','W','E'].includes(meta.entrySide))badRequest(`Invalid entry side for ${meta.id}`);
if(meta.sealedSides!=null&&(!Array.isArray(meta.sealedSides)||meta.sealedSides.some(side=>!['N','S','W','E'].includes(side))))badRequest(`Invalid sealed sides for ${meta.id}`);
const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean);return clean;
const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean);
const derivedLevel=PuzzleCore.solverDifficulty(clean.puzzle,clean.targetLevel);if(clean.level!==derivedLevel)badRequest(`Unverified difficulty for ${clean.id}`);
clean.level=derivedLevel;clean.puzzle.level=derivedLevel;clean.puzzle.difficulty=derivedLevel;clean.puzzle.complexity=PuzzleCore.solutionComplexity(clean.puzzle);clean.puzzle.interactionBurden=AppLogic.interactionBurden(clean.puzzle);
delete clean.puzzle.solutionQuality;delete clean.puzzle.uniqueness;
if(clean.targetLevel>=6){
const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:1000,nodeCap:100000,analyzeQuality:false});
if(verification.status!=='unique'||verification.signature!==PuzzleCore.puzzleSignature(clean.puzzle))badRequest(`Puzzle is not server-verified: ${clean.id}`);
clean.puzzle.uniqueness={status:'unique',signature:verification.signature,ruleVersion:verification.ruleVersion,nodes:verification.nodes};
}
const source=clean.puzzle,bounds=source.bounds&&Number.isInteger(source.bounds.w)&&Number.isInteger(source.bounds.h)&&source.bounds.w>0&&source.bounds.w<=325&&source.bounds.h>0&&source.bounds.h<=325?{w:source.bounds.w,h:source.bounds.h}:null;
clean.puzzle={g:source.g,n:source.n,valid:source.valid,obstacles:Array.isArray(source.obstacles)?source.obstacles:[],specialCells:{crossings:Array.isArray(source.specialCells?.crossings)?source.specialCells.crossings:[],warps:Array.isArray(source.specialCells?.warps)?source.specialCells.warps:[],locks:Array.isArray(source.specialCells?.locks)?source.specialCells.locks:[],internalGates:Array.isArray(source.specialCells?.internalGates)?source.specialCells.internalGates:[]},bounds,axis:typeof source.axis==='string'?source.axis.slice(0,16):'MIX',solution:source.solution,level:derivedLevel,difficulty:derivedLevel,maxTurns:Number.isFinite(source.maxTurns)?source.maxTurns:0,totalTurns:Number.isFinite(source.totalTurns)?source.totalTurns:0,style:typeof source.style==='string'?source.style.slice(0,32):'stored-procedural',complexity:source.complexity,regionalTarget:Number.isInteger(source.regionalTarget)?source.regionalTarget:clean.targetLevel,regionalFallback:source.regionalFallback===true,regionalOutlier:source.regionalOutlier===true,uniqueness:source.uniqueness||null,interactionBurden:source.interactionBurden};
return{id:clean.id,x:clean.x,y:clean.y,chunks:clean.chunks.map(cell=>[...cell]),level:derivedLevel,targetLevel:clean.targetLevel,seed:clean.seed,axis:clean.puzzle.axis,entrySide:['N','S','W','E'].includes(clean.entrySide)?clean.entrySide:null,sealedSides:[...new Set((clean.sealedSides||[]).filter(side=>['N','S','W','E'].includes(side)))],puzzle:clean.puzzle,generatorVersion:BuildMeta.GENERATOR_VERSION};
}
function validateStateRow(row,meta){
if(!row||typeof row!=='object'||!BOARD_RE.test(row.id)||!row.value||typeof row.value!=='object')badRequest('Invalid board state');if(!meta)badRequest(`State without metadata: ${row.id}`);
const state=JSON.parse(JSON.stringify(row.value)),puzzle=validatePuzzle(meta),paths=Array.isArray(state.paths)?state.paths:[];if(paths.length>MAX_PATHS_PER_BOARD)badRequest(`Too many paths for ${row.id}`);
for(const item of paths){if(!item||typeof item!=='object'||!Array.isArray(item.cells)||item.cells.length>MAX_CELLS_PER_PATH)badRequest(`Invalid path for ${row.id}`);if(!Number.isInteger(item.startGate)||item.startGate<0||item.startGate>=puzzle.gates.length||item.endGate!=null&&(!Number.isInteger(item.endGate)||item.endGate<0||item.endGate>=puzzle.gates.length))badRequest(`Invalid path gates for ${row.id}`);const seen=new Set();for(let index=0;index<item.cells.length;index++){const cell=item.cells[index];if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1]))badRequest(`Invalid path cell for ${row.id}`);const key=cellKey(cell);if(!puzzle.valid.has(key)||seen.has(key))badRequest(`Invalid path cell for ${row.id}`);seen.add(key);if(index){const previous=item.cells[index-1],distance=Math.abs(previous[0]-cell[0])+Math.abs(previous[1]-cell[1]);if(distance!==1&&puzzle.warpMap.get(cellKey(previous))!==key)badRequest(`Disconnected path for ${row.id}`)}}}
if(state.solved===true){const solvedKeys=paths.map(route=>route.endGate==null?'':normalizedRouteKey(route.cells)).sort();if(solvedKeys.length!==puzzle.solutionKeys.length||solvedKeys.some((key,index)=>!key||key!==puzzle.solutionKeys[index]))badRequest(`Solved state does not match puzzle for ${row.id}`)}
return state;
for(const item of paths){if(!item||typeof item!=='object'||!Array.isArray(item.cells)||!item.cells.length||item.cells.length>MAX_CELLS_PER_PATH)badRequest(`Invalid path for ${row.id}`);if(!Number.isInteger(item.startGate)||item.startGate<0||item.startGate>=puzzle.gates.length||item.endGate!=null&&(!Number.isInteger(item.endGate)||item.endGate<0||item.endGate>=puzzle.gates.length))badRequest(`Invalid path gates for ${row.id}`);if(item.detachedStart!==true&&!sameCellValue(item.cells[0],puzzle.gates[item.startGate])||item.endGate!=null&&!sameCellValue(item.cells[item.cells.length-1],puzzle.gates[item.endGate]))badRequest(`Path endpoint mismatch for ${row.id}`);const seen=new Set();for(let index=0;index<item.cells.length;index++){const cell=item.cells[index];if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1]))badRequest(`Invalid path cell for ${row.id}`);const key=cellKey(cell);if(!puzzle.valid.has(key)||seen.has(key))badRequest(`Invalid path cell for ${row.id}`);seen.add(key);if(index){const previous=item.cells[index-1],distance=Math.abs(previous[0]-cell[0])+Math.abs(previous[1]-cell[1]);if(distance!==1&&puzzle.warpMap.get(cellKey(previous))!==key)badRequest(`Disconnected path for ${row.id}`)}}}
if(state.solved===true&&!solvedStateMatchesPuzzle(state,puzzle))badRequest(`Solved state does not satisfy puzzle rules for ${row.id}`)
const cleanPaths=paths.map(item=>({startGate:item.startGate,endGate:item.endGate??null,openGate:Number.isInteger(item.openGate)?item.openGate:null,detachedStart:item.detachedStart===true&&item.endGate==null,cells:item.cells.map(cell=>[...cell]),colorIndex:Number.isInteger(item.colorIndex)&&item.colorIndex>=0&&item.colorIndex<64?item.colorIndex:0,startColorIndex:Number.isInteger(item.startColorIndex)&&item.startColorIndex>=0&&item.startColorIndex<64?item.startColorIndex:0,endColorIndex:Number.isInteger(item.endColorIndex)&&item.endColorIndex>=0&&item.endColorIndex<64?item.endColorIndex:null,lineEffect:typeof item.lineEffect==='string'&&item.lineEffect.length<=64?item.lineEffect:null,ownerId:PLAYER_RE.test(String(item.ownerId||''))?String(item.ownerId).toLowerCase():null}));
const crossings=[...new Set((Array.isArray(state.specialProgress?.crossings)?state.specialProgress.crossings:[]).filter(value=>typeof value==='string'&&/^-?\d+,-?\d+$/.test(value)))].slice(0,64);
return{paths:cleanPaths,specialProgress:{crossings},solved:state.solved===true,expanded:state.expanded===true,expansionRetryRound:Number.isInteger(state.expansionRetryRound)&&state.expansionRetryRound>=0?Math.min(state.expansionRetryRound,1000000):0,scoreVersion:Number.isInteger(state.scoreVersion)&&state.scoreVersion>=0?Math.min(state.scoreVersion,1000):0};
}
function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta));delete clean.rev;delete clean.revAuthor;delete clean.sealedSides;return JSON.stringify(clean)}
function sanitizeWorldGlobal(value,record){
const source=value&&typeof value==='object'&&!Array.isArray(value)?value:{},prior=record?.global||{},clean={};
for(const key of['schema','gameplayVersion','worldGeneration','worldEpoch','appVersion','generatorVersion','nextId','solved','lastSolveAt','specialMechanicsSeen','quarantine'])if(Object.prototype.hasOwnProperty.call(source,key))clean[key]=JSON.parse(JSON.stringify(source[key]));
clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?[...new Set(clean.specialMechanicsSeen.filter(type=>['warp','lock','crossing'].includes(type)))]:Array.isArray(prior.specialMechanicsSeen)?prior.specialMechanicsSeen:[];
if(Number.isInteger(source.gameplayVersion)&&source.gameplayVersion>=0&&source.gameplayVersion<=1000)clean.gameplayVersion=source.gameplayVersion;
if(Number.isFinite(source.lastSolveAt)&&source.lastSolveAt>0)clean.lastSolveAt=Math.min(serverTime()+60_000,source.lastSolveAt);
clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?SharedContracts.normalizeSpecialMechanics(clean.specialMechanicsSeen):SharedContracts.normalizeSpecialMechanics(prior.specialMechanicsSeen);
if(Array.isArray(source.specialMechanicsSeen))clean.specialMechanicsSeen=SharedContracts.normalizeSpecialMechanics([...(prior.specialMechanicsSeen||[]),...source.specialMechanicsSeen]);
clean.updatedAt=serverTime();return clean;
}
const STORE_CHANCE=1/30,STORE_PRICE_VERSION=1,EXPANSION_GRANT_TTL_MS=10*60*1000,MAX_NEW_BOARDS_PER_GRANT=8;
const STORE_CHANCE=1/10,STORE_PRICE_VERSION=1,EXPANSION_GRANT_TTL_MS=10*60*1000,MAX_NEW_BOARDS_PER_GRANT=8;
function authoritativeStoreItemIds(seed){
const all=[...STORE_CATALOG.values()],cursor=all.filter(item=>item.cursorStyle),other=all.filter(item=>!item.cursorStyle),
cursorPool=PuzzleCore.shuffle([...cursor],PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x5f356495))),
otherPool=PuzzleCore.shuffle([...other],PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x2c9277b5))),
selected=[...cursorPool.slice(0,12),...otherPool.slice(0,1)];
return PuzzleCore.shuffle(selected,PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x6d2b79f5))).map(item=>item.id);
fixedTools=otherPool.filter(item=>item.scoreLens).slice(0,1),
cosmeticPool=otherPool.filter(item=>!item.scoreLens),
selectedOthers=PuzzleCore.shuffle([...fixedTools,...cosmeticPool.slice(0,6-fixedTools.length)],PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x6d2b79f5)));
return[...cursorPool.slice(0,6),...selectedOthers].map(item=>item.id);
}
function authoritativeReward(meta,state,worldSeed=0){
const level=Math.max(1,Math.min(10,Number(meta.level)||1)),sections=Math.max(1,meta.chunks?.length||1),
@ -141,9 +315,8 @@ function authoritativeReward(meta,state,worldSeed=0){
return{award:Math.max(13,Math.min(Number.MAX_SAFE_INTEGER,reward.award)),identity:reward.identity,coefficient:reward.coefficient};
}
function authoritativeStore(meta,state,playerName,worldSeed=0){
const roll=(PuzzleCore.hash32((meta.seed>>>0)^0x7f4a7c15)>>>0)/4294967296;if(roll>=STORE_CHANCE||!state.paths?.length)return null;
const pathIndex=state.paths.reduce((best,path,index)=>(path.cells?.length||0)>(state.paths[best]?.cells?.length||0)?index:best,0),path=state.paths[pathIndex],cellIndex=Math.max(0,Math.min(path.cells.length-1,Math.floor(path.cells.length*.55))),
store={owner:playerName,pathIndex,cellIndex,openedAt:serverTime(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus:0,bonusVersion:6,itemIds:authoritativeStoreItemIds(meta.seed),purchases:[]},
const roll=(PuzzleCore.hash32((meta.seed>>>0)^0x7f4a7c15)>>>0)/4294967296,obstacles=meta?.puzzle?.obstacles||[];if(roll>=STORE_CHANCE||!obstacles.length)return null;
const cell=[...obstacles[(PuzzleCore.hash32((meta.seed>>>0)^0x2fd51a37)>>>0)%obstacles.length]],store={owner:playerName,pathIndex:-1,cellIndex:-1,cell,openedAt:serverTime(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus:0,bonusVersion:6,itemIds:authoritativeStoreItemIds(meta.seed),purchases:[]},
[x,y]=storeItemPriceLocation(meta,state,store);store.priceCoefficient=AppLogic.deterministicStorePrice(1,worldSeed,x,y,STORE_PRICE_VERSION).coefficient;return store;
}
function boardTouchesWorld(meta,rows){const occupied=new Set();for(const row of rows)for(const[dx,dy]of row?.meta?.chunks||[])occupied.add(`${row.meta.x+dx},${row.meta.y+dy}`);for(const[dx,dy]of meta.chunks||[]){const x=meta.x+dx,y=meta.y+dy;for(const[ox,oy]of[[1,0],[-1,0],[0,1],[0,-1]])if(occupied.has(`${x+ox},${y+oy}`))return true}return false}
@ -151,15 +324,29 @@ function boardTouchesWorld(meta,rows){const occupied=new Set();for(const row of
function publicStateForWorld(incoming,current,player,worldRevision,rowRevision,meta,worldSeed=0){
const now=serverTime(),wasSolved=current?.solved===true,wantsSolved=incoming.solved===true,base={paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,expansionRetryRound:0,solvedBy:null,solvedById:null,solvedAt:null,scoreAwarded:0,scoreVersion:incoming.scoreVersion||0,rewardIdentity:null,rewardCoefficient:null,store:null,rev:rowRevision,revAuthor:'shared-world'};
if(wasSolved){
const stable=JSON.parse(JSON.stringify(current));stable.expanded=stable.expanded===true||incoming.expanded===true;stable.expansionRetryRound=stable.expanded?0:Math.max(stable.expansionRetryRound||0,incoming.expansionRetryRound||0);stable.rev=rowRevision;stable.revAuthor='shared-world';if(stable.store)stable.store.purchases=[];return{state:stable,clearEvent:null};
const stable=JSON.parse(JSON.stringify(current));stable.expanded=stable.expanded===true||incoming.expanded===true;stable.expansionRetryRound=stable.expanded?0:Math.max(stable.expansionRetryRound||0,incoming.expansionRetryRound||0);stable.rev=rowRevision;stable.revAuthor='shared-world';if(stable.store){stable.store.purchases=[];if(!Array.isArray(stable.store.itemIds)||stable.store.itemIds.length!==12)stable.store.itemIds=authoritativeStoreItemIds(meta.seed)}return{state:stable,clearEvent:null};
}
if(!wantsSolved){
const state=JSON.parse(JSON.stringify(incoming));state.solved=false;state.expanded=false;state.expansionRetryRound=0;state.solvedBy=null;state.solvedById=null;state.solvedAt=null;state.scoreAwarded=0;state.rewardIdentity=null;state.rewardCoefficient=null;state.store=null;state.rev=rowRevision;state.revAuthor='shared-world';return{state,clearEvent:null};
}
if(!wantsSolved)return{state:base,clearEvent:null};
const state=JSON.parse(JSON.stringify(incoming));state.solved=true;state.solvedBy=player.name;state.solvedById=player.playerId;state.solvedAt=now;state.rev=rowRevision;state.revAuthor='shared-world';state.expanded=false;state.expansionRetryRound=0;const reward=authoritativeReward(meta,state,worldSeed);state.scoreAwarded=reward.award;state.scoreVersion=6;state.rewardIdentity=reward.identity;state.rewardCoefficient=reward.coefficient;state.store=authoritativeStore(meta,state,player.name,worldSeed);
return{state,clearEvent:{id:meta.id,playerId:player.playerId,playerName:player.name,x:meta.x,y:meta.y,level:meta.level,scoreAwarded:state.scoreAwarded,solvedAt:now,revision:worldRevision}};
}
function cloudDeltaSince(record,since){if(!(since>0)||since>=record.revision)return null;const changes=record.changes.filter(change=>Number.isSafeInteger(change?.revision)&&change.revision>since).sort((a,b)=>a.revision-b.revision);if(!changes.length||changes[0].revision!==since+1||changes[changes.length-1].revision!==record.revision)return null;for(let index=1;index<changes.length;index++)if(changes[index].revision!==changes[index-1].revision+1)return null;const metaIds=new Set(),stateIds=new Set(),deleted=new Set();for(const change of changes){for(const id of change.deleted||[]){metaIds.delete(id);stateIds.delete(id);deleted.add(id)}for(const id of change.metaIds||[]){deleted.delete(id);metaIds.add(id)}for(const id of change.stateIds||[]){deleted.delete(id);stateIds.add(id)}}return{metaIds,stateIds,deleted}}
async function publicWorldPage(record,ids,metaIds=null,stateIds=null){const rows=await Promise.all(ids.map(id=>readWorldBoard(record,id))),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=row.state}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
async function readWorldBoardsBounded(record,ids){const rows=[];for(let offset=0;offset<ids.length;offset+=CLOUD_READ_CONCURRENCY)rows.push(...await Promise.all(ids.slice(offset,offset+CLOUD_READ_CONCURRENCY).map(id=>readWorldBoard(record,id))));return rows}
function completedProgressState(state){if(!state||state.solved===true)return state;const visible=JSON.parse(JSON.stringify(state));visible.paths=(visible.paths||[]).filter(path=>Number.isInteger(path?.endGate));visible.specialProgress={crossings:[]};return visible}
function completedProgressFingerprint(state){const visible=completedProgressState(state)||{};return JSON.stringify({paths:visible.paths||[],solved:visible.solved===true,expanded:visible.expanded===true})}
async function publicWorldPage(record,ids,metaIds=null,stateIds=null,viewerPlayerId=''){const rows=await readWorldBoardsBounded(record,ids),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=realtimeHub?.hasClaim(viewerPlayerId,id)?row.state:completedProgressState(row.state)}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
function withWorldQueue(task){const run=worldQueue.then(task,task);worldQueue=run.then(()=>undefined,()=>undefined);return run}
function canRebaseWorldPush(record,baseRevision,metas,states){
if(baseRevision===record.revision)return true;
if(baseRevision<0||baseRevision>record.revision)return false;
const delta=cloudDeltaSince(record,baseRevision);if(!delta)return false;
const touched=new Set([...metas.map(meta=>String(meta?.id||'')),...states.map(row=>String(row?.id||''))]);
if(metas.some(meta=>!Object.prototype.hasOwnProperty.call(record.boardVersions,meta?.id)))return false;
for(const id of touched)if(delta.metaIds.has(id)||delta.stateIds.has(id)||delta.deleted.has(id))return false;
return true;
}
async function authenticateRealtime({playerId,token}){
if(!PLAYER_RE.test(String(playerId||''))||!TOKEN_RE.test(String(token||'')))throw new Error('Unauthorized');
const normalizedId=String(playerId).toLowerCase(),record=await readPlayer(normalizedId);
@ -174,14 +361,14 @@ async function realtimeBoardInfo(boardId){
function assertNoOverlaps(rows){const occupied=new Map();for(const row of rows){const meta=row?.meta;if(!meta)continue;for(const[dx,dy]of meta.chunks){const key=`${meta.x+dx},${meta.y+dy}`,prior=occupied.get(key);if(prior&&prior!==meta.id)badRequest(`Board overlap: ${prior} / ${meta.id}`);occupied.set(key,meta.id)}}}
function storeItemPriceLocation(meta,state,store){
const path=store&&state?.paths?.[store.pathIndex],cell=path?.cells?.[store.cellIndex];
const direct=Array.isArray(store?.cell)?store.cell:null,path=store&&state?.paths?.[store.pathIndex],cell=direct||path?.cells?.[store.cellIndex];
if(Array.isArray(cell))return[meta.x+(cell[1]+.5)/5,meta.y+(cell[0]+.5)/5];
const count=Math.max(1,meta.chunks?.length||1),x=meta.x+.5+(meta.chunks||[]).reduce((sum,chunk)=>sum+chunk[0],0)/count,y=meta.y+.5+(meta.chunks||[]).reduce((sum,chunk)=>sum+chunk[1],0)/count;return[x,y];
}
async function storePurchaseContext(world,boardId,itemId){
if(!BOARD_RE.test(String(boardId||'')))badRequest('Invalid store board');const item=STORE_CATALOG.get(itemId);if(!item)badRequest('Invalid store item');
const row=await readWorldBoard(world,boardId);if(!row?.meta||row.state?.solved!==true||!row.state?.store)badRequest('Store is not available');
const store=row.state.store,itemIds=Array.isArray(store.itemIds)?store.itemIds:[];if(!itemIds.includes(item.id))badRequest('Item is not sold by this store');
const store=row.state.store,itemIds=Array.isArray(store.itemIds)&&store.itemIds.length===12?store.itemIds:authoritativeStoreItemIds(row.meta.seed);if(!itemIds.includes(item.id))badRequest('Item is not sold by this store');
const starter=await readWorldBoard(world,'B0'),worldSeed=starter?.meta?.seed||0,[x,y]=storeItemPriceLocation(row.meta,row.state,store),computed=AppLogic.deterministicStorePrice(1,worldSeed,x,y,store.priceVersion||1),coefficient=computed.coefficient,adjusted=Math.round(Math.max(0,Number(item.cost)||0)*coefficient),price=item.id.startsWith('cursor-face-')?Math.max(500,Math.min(50000,adjusted)):Math.max(item.cursorStyle?500:3000,adjusted);
return{row,item,price};
}
@ -202,61 +389,289 @@ function createPlayerPurchase(record,boardId,item,price,timestamp=serverTime()){
return record.purchases.find(row=>row.purchaseId===purchase.purchaseId)||purchase;
}
async function writePlayerRecord(record){record.purchases=normalizePlayerPurchases(record.purchases);record.generationBonuses=normalizeGenerationBonuses(record.generationBonuses);await atomicWriteJson(playerPath(record.playerId),record)}
const playerService=createPlayerService({
randomHex:bytes=>crypto.randomBytes(bytes).toString('hex'),
now:serverTime,
cleanName:cleanPlayerName,
defaultName:defaultPlayerName,
hashToken:tokenHash,
writePlayer:writePlayerRecord,
readPlayer,
withPlayerQueue,
withWorldQueue,
readWorld,
readWorldBoard,
normalizeBonuses:normalizeGenerationBonuses,
earnedScore:playerEarnedScore,
publicState:publicPlayerState,
bonusAmount:GENERATION_FAILURE_BONUS,
bonusDelayMs:GENERATION_FAILURE_MIN_DELAY_MS,
notifyProfile:(playerId,name)=>realtimeHub?.notifyProfileChange(playerId,name),
purchaseContext:storePurchaseContext,
findPurchase:purchaseForStoreItem,
assertAffordable:assertPlayerCanAfford,
createPurchase:createPlayerPurchase,
starterLineColor:starterLineColorForPlayer,
boardPattern:BOARD_RE
});
async function handleApi(req,res,url){
if(req.method==='GET'&&url.pathname==='/api/cloud/status')return json(res,200,{available:true,sharedWorld:true,realtime:true,reactions:true,playerEconomy:true,sharedItems:false,claimTtlMs:realtimeHub?.claimTtlMs||300000,serverTime:serverTime()});
if(req.method==='POST'&&url.pathname==='/api/cloud/session'){
const body=await readJsonBody(req),playerId=crypto.randomBytes(12).toString('hex'),token=crypto.randomBytes(32).toString('hex'),now=serverTime(),name=cleanPlayerName(body.name,defaultPlayerName(playerId));
const record={playerId,name,tokenHash:tokenHash(token),purchases:[],generationBonuses:[],earnedScore:0,economyRevision:0,createdAt:now,updatedAt:now};await atomicWriteJson(playerPath(playerId),record);return json(res,201,{playerId,token,name,revision:0,serverTime:now});
}
if(req.method==='POST'&&url.pathname==='/api/cloud/profile'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),name=cleanPlayerName(body.name);if(!name)badRequest('Player name is required');auth.record.name=name;auth.record.updatedAt=serverTime();await atomicWriteJson(playerPath(auth.playerId),auth.record);realtimeHub?.notifyProfileChange(auth.playerId,name);return json(res,200,{playerId:auth.playerId,name,serverTime:auth.record.updatedAt});
}
if(req.method==='GET'&&url.pathname==='/api/player/state'){
const player=await authenticatedPlayer(req);return json(res,200,{player:publicPlayerState(player.record),serverTime:serverTime()});
}
if(req.method==='POST'&&url.pathname==='/api/player/generation-bonus'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),boardId=String(body.boardId||'');if(!BOARD_RE.test(boardId))badRequest('Invalid board id');
return withPlayerQueue(auth.playerId,()=>withWorldQueue(async()=>{const record=await readPlayer(auth.playerId),world=await readWorld(),row=await readWorldBoard(world,boardId),now=serverTime();if(!row?.state?.solved||row.state.solvedById!==auth.playerId)throw Object.assign(new Error('Only the solver can receive this bonus'),{status:403});if(row.state.expanded===true)throw Object.assign(new Error('Expansion already succeeded'),{status:409});if(now-(Number(row.state.solvedAt)||0)<GENERATION_FAILURE_MIN_DELAY_MS)throw Object.assign(new Error('Generation attempt is still in progress'),{status:409});const grant=world.expansionGrants?.[auth.playerId];if(!grant||grant.boardId!==boardId||grant.expiresAt<now||!(grant.maxBoards>0))throw Object.assign(new Error('No failed expansion is eligible'),{status:409});record.generationBonuses=normalizeGenerationBonuses(record.generationBonuses);if(!record.generationBonuses.includes(boardId)){record.generationBonuses.push(boardId);record.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(record)+GENERATION_FAILURE_BONUS);record.economyRevision=(record.economyRevision||0)+1;record.updatedAt=now;await writePlayerRecord(record)}return json(res,200,{bonus:GENERATION_FAILURE_BONUS,player:publicPlayerState(record),serverTime:now})}));
}
if(req.method==='POST'&&url.pathname==='/api/player/purchase'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),boardId=String(body.boardId||''),itemId=String(body.itemId||'');
return withPlayerQueue(auth.playerId,async()=>{const record=await readPlayer(auth.playerId),world=await readWorld(),context=await storePurchaseContext(world,boardId,itemId),existing=purchaseForStoreItem(record,boardId,itemId);if(existing)return json(res,200,{purchase:existing,player:publicPlayerState(record),serverTime:serverTime()});await assertPlayerCanAfford(world,record,context.price);const purchase=createPlayerPurchase(record,boardId,context.item,context.price);await writePlayerRecord(record);return json(res,201,{purchase,player:publicPlayerState(record),serverTime:record.updatedAt})});
}
if(req.method==='GET'&&url.pathname==='/api/cloud/pull'){
const player=await authenticatedPlayer(req),record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('eventsSince'),0))),changed=since!==record.revision,
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince);
if(!changed)return json(res,200,{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime()});
const cursor=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('at'),record.revision)));if(cursor&&at!==record.revision)return json(res,409,{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()});
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds);
return json(res,200,{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor});
}
if(req.method==='POST'&&url.pathname==='/api/cloud/push'){
const player=await authenticatedPlayer(req),body=await readJsonBody(req);
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
const playerRecord=await readPlayer(player.playerId),record=await readWorld(),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0)));if(baseRevision!==record.revision)return json(res,409,{error:'Revision conflict',revision:record.revision,serverTime:serverTime()});
const metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];if(metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),previousVersions=new Map(),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);previousVersions.set(id,record.boardVersions[id]);return current};
for(const rawMeta of metas){const clean=validateMeta(rawMeta),row=await loadChangedBoard(clean.id);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
const existingIds=new Set(Object.keys(record.boardVersions)),newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id));
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');const existingRows=[];for(const id of existingIds){const row=await readWorldBoard(record,id);if(row)existingRows.push(row)}const accepted=[];for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1)))){const row=changedBoards.get(id);if(!boardTouchesWorld(row.meta,[...existingRows,...accepted]))badRequest(`Generated board is not adjacent: ${id}`);accepted.push(row)}grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
const clearEvents=[];
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true;if(firstSolve&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});const starter=await readWorldBoard(record,'B0'),worldSeed=starter?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
async function handleCloudStatus(_req,res){
const world=await readWorld();
return json(res,200,{available:true,sharedWorld:true,singleWorld:true,worldId:'link-field-main',appVersion:BuildMeta.APP_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,realtime:true,reactions:true,playerEconomy:true,sharedItems:false,revision:world.revision||0,boardCount:Object.keys(world.boardVersions||{}).length,claimTtlMs:realtimeHub?.claimTtlMs||300000,serverTime:serverTime()});
}
async function handleCloudSession(req,res){
const ip=requestIp(req);rateLimit(`session:${ip}`,30,60_000);const body=await readJsonBody(req),result=await withPlayerCapacity(async()=>{await assertPlayerCapacity();const created=await playerService.createSession(body.name);playerRecordCount++;return created});return json(res,result.status,result.body);
}
async function handleCloudProfile(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.updateProfile(auth.playerId,body.name);return json(res,result.status,result.body);
}
async function handlePlayerState(req,res){
const player=await authenticatedPlayer(req),result=playerService.getState(player.record);return json(res,result.status,result.body);
}
async function handleGenerationBonus(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.awardGenerationBonus(auth.playerId,String(body.boardId||''));return json(res,result.status,result.body);
}
async function handlePurchase(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.purchase(auth.playerId,String(body.boardId||''),String(body.itemId||''));return json(res,result.status,result.body);
}
async function pullCloudWorldService(player,parameters){
const record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(parameters.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(parameters.get('eventsSince'),0))),changed=since!==record.revision,
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince),clearEventsGap=eventsSince>0&&eventsSince<(record.clearEventsDroppedThrough||0);
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,clearEvents,clearEventsGap,player:publicCloudPlayer(player.record),serverTime:serverTime()}};
const cursor=Math.max(0,Math.floor(finiteNumber(parameters.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(parameters.get('at'),record.revision)));if(cursor&&at!==record.revision)return{status:409,body:{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()}};
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds,player.playerId);
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,clearEvents:cursor?[]:clearEvents,clearEventsGap:cursor?false:clearEventsGap,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
}
async function pushCloudWorldService(player,body){
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
const playerRecord=await readPlayer(player.playerId),previousPlayerRecord=JSON.parse(JSON.stringify(playerRecord)),record=await ensureWorldIndexes(JSON.parse(JSON.stringify(await readWorld()))),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0))),requestedMutationId=String(body.mutationId||''),mutationId=requestedMutationId||`legacy-${crypto.randomBytes(16).toString('hex')}`,metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];
if(requestedMutationId&&!MUTATION_RE.test(requestedMutationId))badRequest('Invalid mutation id');const priorMutation=requestedMutationId&&(record.recentMutations||[]).find(row=>row?.playerId===player.playerId&&row?.mutationId===mutationId);if(priorMutation){const clearEvents=record.clearEvents.filter(event=>event.revision===priorMutation.revision&&event.playerId===player.playerId);return{status:200,body:{revision:priorMutation.revision,duplicate:true,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,player:publicCloudPlayer(playerRecord),serverTime:serverTime()}}}
if(metas.length>MAX_METAS_PER_PUSH||states.length>MAX_BOARDS_PER_PUSH||metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
const metaIds=metas.map(meta=>String(meta?.id||'')),stateIds=states.map(row=>String(row?.id||''));if(new Set(metaIds).size!==metaIds.length||new Set(stateIds).size!==stateIds.length)badRequest('Duplicate board changes');
if(!canRebaseWorldPush(record,baseRevision,metas,states))return{status:409,body:{error:'Revision conflict',revision:record.revision,serverTime:serverTime()}};
if(metaIds.some(id=>!BOARD_RE.test(id)))badRequest('Invalid board metadata');const existingIds=new Set(Object.keys(record.boardVersions)),rawNewMetaIds=metaIds.filter(id=>!existingIds.has(id));if(rawNewMetaIds.length>MAX_NEW_BOARDS_PER_GRANT)badRequest('Too many generated boards');
let expansionGrant=null;if(record.revision>0&&rawNewMetaIds.length){expansionGrant=record.expansionGrants?.[player.playerId];if(!expansionGrant||expansionGrant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(rawNewMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,expansionGrant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=rawNewMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous')}
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),visibleBoardIds=new Set(metaIds),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);return current};
for(const rawMeta of metas){const row=await loadChangedBoard(rawMeta.id),unchanged=row.meta&&rawMeta&&typeof rawMeta==='object'&&worldMetaFingerprint(row.meta)===worldMetaFingerprint(rawMeta),clean=unchanged?JSON.parse(JSON.stringify(row.meta)):validateMeta(rawMeta);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
const newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
if(record.revision>0&&newMetaIds.length){for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});expansionGrant.maxBoards-=newMetaIds.length;if(expansionGrant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
else for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy);
const clearEvents=[];let solved=Math.max(0,Number(record.global?.solved)||0),earnedScore=Math.max(0,Number(record.global?.earnedScore)||0),starterRow=null;
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true,unfinishedBoard=row.state?.solved!==true,existingBoard=existingIds.has(rawRow.id),previousProgress=completedProgressFingerprint(row.state);if(record.revision>0&&existingBoard&&unfinishedBoard&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423,boardId:rawRow.id});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(previousProgress!==completedProgressFingerprint(row.state))visibleBoardIds.add(rawRow.id);if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
for(const [id,row] of changedBoards){if(!row.meta)badRequest(`Board metadata is missing: ${id}`);if(!row.state)row.state=publicStateForWorld({},null,playerRecord,nextRevision,rowRevision,row.meta).state}
const allIds=new Set([...Object.keys(record.boardVersions),...changedBoards.keys()]),allRows=[];for(const id of allIds){const row=changedBoards.get(id)||await readWorldBoard(record,id);if(row)allRows.push(row)}assertNoOverlaps(allRows);
await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision}
record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record)};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));let solved=0,earnedScore=0;for(const row of allRows)if(row.state?.solved){solved++;const award=Number(row.state.scoreAwarded)||0;if(Number.isSafeInteger(award)&&award>0)earnedScore+=award}record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
const retiredVersions=[];await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){const previousRevision=record.boardVersions[id];await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision;if(Number.isSafeInteger(previousRevision)&&previousRevision!==nextRevision)retiredVersions.push({id,revision:previousRevision})}
record.occupancy=occupancy;record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record),schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
record.changes.push({revision:nextRevision,metaIds:metas.map(meta=>meta.id),stateIds:states.map(row=>row.id),deleted});if(record.changes.length>CHANGE_HISTORY_LIMIT)record.changes.splice(0,record.changes.length-CHANGE_HISTORY_LIMIT);
record.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT)record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_LIMIT);
if(clearEvents.length)await writePlayerRecord(playerRecord);await atomicWriteJson(WORLD_FILE,record);await Promise.all([...previousVersions].map(([id,revision])=>Number.isSafeInteger(revision)&&revision!==nextRevision?fsp.unlink(worldBoardVersionPath(id,revision)).catch(error=>{if(error.code!=='ENOENT')console.warn(error)}):null));
for(const event of clearEvents)realtimeHub?.releaseBoardClaim(event.id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
return json(res,200,{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:{id:player.playerId,name:player.record.name},serverTime:record.updatedAt});
}));
}
return json(res,404,{error:'API endpoint not found',serverTime:serverTime()});
record.recentMutations=Array.isArray(record.recentMutations)?record.recentMutations:[];record.recentMutations.push({playerId:player.playerId,mutationId,revision:nextRevision,committedAt:record.updatedAt});if(record.recentMutations.length>1024)record.recentMutations.splice(0,record.recentMutations.length-1024);
record.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT){const removed=record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_LIMIT);record.clearEventsDroppedThrough=Math.max(record.clearEventsDroppedThrough||0,...removed.map(event=>Number(event?.revision)||0))}
await commitWorldMutation(record,{nextPlayer:clearEvents.length?playerRecord:null,previousPlayer:clearEvents.length?previousPlayerRecord:null,changedBoardIds:changedBoards.keys()});
await removeRetiredBoardVersions(retiredVersions);
const realtimePage={global:{...(record.global||{}),cloudProfile:null,cloudRevision:nextRevision},metas:{},states:{}};for(const id of visibleBoardIds){const row=changedBoards.get(id);if(!row)continue;if(metaIds.includes(id)&&row.meta)realtimePage.metas[id]=row.meta;if(row.state)realtimePage.states[id]=completedProgressState(row.state)}
for(const[id,row]of changedBoards)if(row.state?.solved===true)realtimeHub?.releaseBoardClaim(id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);if(visibleBoardIds.size)realtimeHub?.broadcastWorldRevision(nextRevision,visibleBoardIds,realtimePage);
return{status:200,body:{revision:nextRevision,duplicate:false,rebased:baseRevision!==record.revision-1,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,player:publicCloudPlayer(playerRecord),serverTime:record.updatedAt}};
}));
}
async function handleCloudPull(req,res,url){
const player=await authenticatedPlayer(req),result=await pullCloudWorldService(player,url.searchParams);return json(res,result.status,result.body);
}
async function handleCloudPush(req,res){
const player=await authenticatedPlayer(req),ip=requestIp(req);rateLimit(`push:${player.playerId}`,60,60_000);const body=await readJsonBody(req);if(Array.isArray(body.metas)&&body.metas.length){const cost=body.metas.length;rateLimit(`meta-push:${player.playerId}`,16,60_000,cost);rateLimit(`meta-push-ip:${ip}`,32,60_000,cost);rateLimit('meta-push-global',32,60_000,cost)}const result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
}
async function pollingIdentity(req){
const player=await authenticatedPlayer(req);return{playerId:player.playerId,name:player.record.name};
}
async function handleRealtimeConnect(req,res){
const identity=await pollingIdentity(req);rateLimit(`realtime-connect:${identity.playerId}`,20,60_000);const result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime capacity has been reached'),{status:503});return json(res,200,result);
}
async function handleRealtimeClaim(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.claimBoard(identity,body.boardId,body.presenceId);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
}
async function handleRealtimeSend(req,res){
const identity=await pollingIdentity(req);rateLimit(`realtime-send:${identity.playerId}`,600,60_000);const body=await readJsonBody(req),result=await realtimeHub?.handlePollingMessage(identity,String(body.presenceId||''),body.message,body.afterSequence);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimePoll(req,res,url){
const identity=await pollingIdentity(req),wait=Math.max(0,Math.min(25_000,Math.floor(finiteNumber(url.searchParams.get('wait'),20_000)))),result=await realtimeHub?.pollPollingClient(identity,String(url.searchParams.get('presenceId')||''),url.searchParams.get('after'),wait);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimeDisconnect(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),disconnected=realtimeHub?.disconnectPollingClient(identity,String(body.presenceId||''))===true;return json(res,200,{disconnected,serverTime:serverTime()});
}
const apiRouter=createHttpRouter({notFound:(_req,res)=>json(res,404,{error:'API endpoint not found',serverTime:serverTime()})});
apiRouter
.add('GET','/api/cloud/status',handleCloudStatus)
.add('POST','/api/cloud/session',handleCloudSession)
.add('POST','/api/cloud/profile',handleCloudProfile)
.add('GET','/api/player/state',handlePlayerState)
.add('POST','/api/player/generation-bonus',handleGenerationBonus)
.add('POST','/api/player/purchase',handlePurchase)
.add('GET','/api/cloud/pull',handleCloudPull)
.add('POST','/api/cloud/push',handleCloudPush)
.add('POST','/api/realtime/connect',handleRealtimeConnect)
.add('POST','/api/realtime/claim',handleRealtimeClaim)
.add('POST','/api/realtime/send',handleRealtimeSend)
.add('GET','/api/realtime/poll',handleRealtimePoll)
.add('POST','/api/realtime/disconnect',handleRealtimeDisconnect);
async function handleApi(req,res,url){return apiRouter.dispatch(req,res,url)}
async function serveStatic(req,res,url){
if(!['GET','HEAD'].includes(req.method))return json(res,405,{error:'Method not allowed'});let pathname;try{pathname=decodeURIComponent(url.pathname)}catch{return json(res,400,{error:'Invalid path'})}if(pathname==='/')pathname='/index.html';const file=path.resolve(ROOT,`.${pathname}`);if(!file.startsWith(`${ROOT}${path.sep}`)||file.startsWith(`${DATA_DIR}${path.sep}`)||path.basename(file).startsWith('.'))return json(res,403,{error:'Forbidden'});let stat;try{stat=await fsp.stat(file)}catch(error){if(error.code==='ENOENT')return json(res,404,{error:'Not found'});throw error}if(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'});const ext=path.extname(file).toLowerCase();let body=null;if(pathname==='/index.html')body=Buffer.from((await fsp.readFile(file,'utf8')).replace('name="bend-field-cloud-api" content="off"','name="bend-field-cloud-api" content="on"'));res.writeHead(200,{'content-type':MIME[ext]||'application/octet-stream','content-length':body?body.length:stat.size,'cache-control':ext==='.html'||ext==='.js'||ext==='.css'?'no-store':'public, max-age=86400','x-content-type-options':'nosniff','cross-origin-resource-policy':'same-origin','content-security-policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"});if(req.method==='HEAD')return res.end();if(body)return res.end(body);const stream=fs.createReadStream(file);stream.on('error',error=>{console.error(error);if(!res.headersSent)json(res,error.code==='ENOENT'?404:500,{error:error.code==='ENOENT'?'Not found':'Internal server error'});else res.destroy(error)});stream.pipe(res);
if(!['GET','HEAD'].includes(req.method))return json(res,405,{error:'Method not allowed'});
let pathname;try{pathname=decodeURIComponent(url.pathname)}catch{return json(res,400,{error:'Invalid path'})}
if(pathname==='/'||pathname.endsWith('/')||pathname.endsWith('/debug-items'))pathname='/index.html';
const candidates=[pathname];for(let offset=pathname.indexOf('/',1);offset>=0;offset=pathname.indexOf('/',offset+1))candidates.push(pathname.slice(offset));
let file=null,stat=null,resolvedPathname=pathname;const publicReal=await fsp.realpath(PUBLIC_ROOT),withinPublic=target=>{const relative=path.relative(publicReal,target);return relative===''||relative!=='..'&&!relative.startsWith(`..${path.sep}`)&&!path.isAbsolute(relative)};
for(const candidate of candidates){const relative=candidate.replace(/^\/+/,''),top=relative.split('/')[0];if(!PUBLIC_STATIC_ROOTS.has(top))continue;const target=path.resolve(PUBLIC_ROOT,relative);if(path.basename(target).startsWith('.'))continue;try{const realTarget=await fsp.realpath(target);if(!withinPublic(realTarget))continue;const targetStat=await fsp.stat(realTarget);if(targetStat.isFile()){file=realTarget;stat=targetStat;resolvedPathname=candidate;break}}catch(error){if(error.code!=='ENOENT')throw error}}
if(!file||!stat)return json(res,404,{error:'Not found'});
if(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'});
const ext=path.extname(file).toLowerCase(),body=resolvedPathname==='/runtime-config.js'?Buffer.from("'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'auto'});})(globalThis);\n"):null;
res.writeHead(200,{
'content-type':MIME[ext]||'application/octet-stream',
'content-length':body?body.length:stat.size,
'cache-control':ext==='.html'||resolvedPathname==='/runtime-config.js'?'no-store':ext==='.js'||ext==='.css'?'public, max-age=300, stale-while-revalidate=3600':'public, max-age=86400',
'x-content-type-options':'nosniff',
'cross-origin-resource-policy':'same-origin',
'content-security-policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"
});
if(req.method==='HEAD')return res.end();
if(body)return res.end(body);
const stream=fs.createReadStream(file);
stream.on('error',error=>{console.error(error);if(!res.headersSent)json(res,error.code==='ENOENT'?404:500,{error:error.code==='ENOENT'?'Not found':'Internal server error'});else res.destroy(error)});
stream.pipe(res);
}
function listenOnce(server, port, host) {
return new Promise((resolve, reject) => {
const cleanup = () => {
server.off('error', onError);
server.off('listening', onListening);
};
const onError = error => { cleanup(); reject(error); };
const onListening = () => { cleanup(); resolve(server.address()); };
server.once('error', onError);
server.once('listening', onListening);
server.listen({port, host});
});
}
async function listenWithPortFallback(server, {host=HOST, preferredPort=PORT, explicitPort=PORT_EXPLICIT, candidates=null}={}) {
const ports = explicitPort ? [preferredPort] : uniquePorts(candidates || defaultPortCandidates(preferredPort));
let lastError = null;
for (let index = 0; index < ports.length; index++) {
const requestedPort = ports[index];
try {
const address = await listenOnce(server, requestedPort, host);
const actualPort = typeof address === 'object' && address ? address.port : requestedPort;
return {address, port:actualPort, requestedPort, preferredPort, usedFallback:index > 0};
} catch (error) {
lastError = error;
if (error?.code !== 'EADDRINUSE') throw error;
if (explicitPort) {
const configuredBy = CLI_PORT != null ? '--port' : process.env.LINK_FIELD_PORT != null ? 'LINK_FIELD_PORT' : 'PORT';
throw Object.assign(new Error(`Port ${requestedPort} is already in use. Change ${configuredBy} or stop the process using that port.`), {code:'EADDRINUSE', cause:error});
}
console.warn(`Port ${requestedPort} is already in use; trying another port.`);
}
}
throw Object.assign(new Error(`No available port was found. Tried: ${ports.join(', ')}`), {code:lastError?.code || 'EADDRINUSE', cause:lastError});
}
function createApplicationServer() {
const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`),apiOffset=url.pathname.lastIndexOf('/api/');if(apiOffset>=0){rateLimit(`api:${requestIp(req)}`,600,60_000);url.pathname=url.pathname.slice(apiOffset);await handleApi(req,res,url)}else await serveStatic(req,res,url)}catch(error){if((error.status||500)>=500)console.error(error);else if(process.env.LINK_FIELD_LOG_CLIENT_ERRORS==='1')console.warn(`${req.method} ${req.url}: ${error.message}`);if(!res.headersSent){if(error.retryAfter)res.setHeader('retry-after',String(error.retryAfter));json(res,error.status||500,{error:error.status?error.message:'Internal server error',...(error.boardId?{boardId:error.boardId}:{}),serverTime:serverTime()})}else res.destroy()}});
server.requestTimeout=35_000;server.headersTimeout=10_000;server.keepAliveTimeout=5_000;server.maxRequestsPerSocket=100;
return server;
}
function displayServerUrl(host, port) {
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
return `http://${displayHost}:${port}`;
}
function processIsRunning(pid){
if(!Number.isSafeInteger(pid)||pid<=0)return false;
try{process.kill(pid,0);return true}catch(error){return error?.code==='EPERM'}
}
async function processLooksLikeServer(pid){
if(process.platform==='linux')try{const command=(await fsp.readFile(`/proc/${pid}/cmdline`)).toString('utf8').replace(/\0/g,' '),cwd=path.resolve(await fsp.readlink(`/proc/${pid}/cwd`)),entryMatches=command.includes(path.basename(__filename))||command.includes('service-control.js');return entryMatches&&(command.includes(ROOT)||cwd===ROOT)}catch(error){if(error?.code==='ENOENT')return false}
return null;
}
async function readInstanceLock(){try{const raw=(await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim();if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true,heartbeatAt:0};const value=JSON.parse(raw);return value&&typeof value==='object'?value:null}catch(error){if(error?.code==='ENOENT')return null;return null}}
async function refreshInstanceLock(){
if(!instanceLockToken)return;const current=await readInstanceLock();if(current?.pid!==process.pid||current?.token!==instanceLockToken)return;
await atomicWriteJson(INSTANCE_LOCK_FILE,{pid:process.pid,token:instanceLockToken,root:ROOT,heartbeatAt:serverTime()});
}
async function acquireInstanceLock(){
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
for(let attempt=0;attempt<2;attempt++){
try{
const handle=await fsp.open(INSTANCE_LOCK_FILE,'wx',0o600);
instanceLockToken=crypto.randomBytes(16).toString('hex');await handle.writeFile(`${JSON.stringify({pid:process.pid,token:instanceLockToken,root:ROOT,heartbeatAt:serverTime()})}\n`,'utf8');
await handle.close();
instanceLockHeartbeatTimer=setInterval(()=>{void refreshInstanceLock().catch(error=>console.warn(`Instance lock heartbeat warning: ${error.message}`))},2000);instanceLockHeartbeatTimer.unref?.();
return process.pid;
}catch(error){
if(error?.code!=='EEXIST')throw error;
const lock=await readInstanceLock(),existing=Number(lock?.pid)||0;let active=false;if(processIsRunning(existing)){const identity=await processLooksLikeServer(existing);active=identity===true||identity==null&&(!lock?.heartbeatAt||serverTime()-lock.heartbeatAt<10_000)}
if(active)throw Object.assign(new Error(`Another LinkField server is already running (PID ${existing}). Stop it before starting a second shared world.`),{code:'EALREADY'});
await fsp.unlink(INSTANCE_LOCK_FILE).catch(unlinkError=>{if(unlinkError?.code!=='ENOENT')throw unlinkError});
}
}
throw new Error('Could not acquire the LinkField single-world lock.');
}
async function releaseInstanceLock(){
clearInterval(instanceLockHeartbeatTimer);instanceLockHeartbeatTimer=null;try{const owner=await readInstanceLock();if(owner?.pid===process.pid&&(!owner.token||owner.token===instanceLockToken))await fsp.unlink(INSTANCE_LOCK_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Instance lock cleanup warning: ${error.message}`)}finally{instanceLockToken=null}
}
async function createAutomaticWorldBackup(){let failure=null;for(let attempt=0;attempt<3;attempt++){try{return await createWorldBackup(DATA_DIR,BACKUP_DIR,{retain:7,appVersion:BuildMeta.APP_VERSION})}catch(error){failure=error;if(attempt<2)await new Promise(resolve=>setTimeout(resolve,250))}}throw failure}
async function main(){
await acquireInstanceLock();
let server=null;
try {
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
await fsp.mkdir(PUBLIC_ROOT,{recursive:true,mode:0o755});
await recoverPendingWorldCommit();
server=createApplicationServer();
realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo,maxClients:500,maxClientsPerPlayer:5});
const listening=await listenWithPortFallback(server,{explicitPort:PORT_STRICT});
console.log(`LinkField v${BuildMeta.APP_VERSION} shared world: ${displayServerUrl(HOST,listening.port)}`);
if(listening.usedFallback)console.log(`Default port ${listening.preferredPort} was unavailable; using port ${listening.port}.`);
await fsp.writeFile(PUBLIC_BRIDGE_PORT_FILE, `${listening.port}\n`, {encoding:'utf8', mode:0o644});
console.log(`Public PHP bridge: ${PUBLIC_BRIDGE_PORT_FILE} -> 127.0.0.1:${listening.port}`);
let apacheBridge=null;
try {
apacheBridge=await installApacheBridge({fsp,root:PUBLIC_ROOT,port:listening.port,enabled:APACHE_BRIDGE_ENABLED});
if(apacheBridge.enabled)console.log(`Apache bridge: ${apacheBridge.file} -> 127.0.0.1:${listening.port}`);
else console.log('Apache bridge: disabled by LINK_FIELD_APACHE_BRIDGE');
} catch (error) {
console.warn(`Apache bridge could not be installed: ${error.message}`);
console.warn('The public /api/ path must be forwarded to this Node.js port by the web server.');
}
console.log(`Shared world data: ${DATA_DIR}`);
void withWorldQueue(()=>collectRetiredBoardVersions()).then(count=>{if(count)console.log(`Removed ${count} retired board version${count===1?'':'s'}.`)}).catch(error=>console.warn(`Retired board cleanup warning: ${error.message}`));
const maintenanceInterval=setInterval(()=>{void withWorldQueue(()=>collectRetiredBoardVersions()).catch(error=>console.warn(`Retired board cleanup warning: ${error.message}`))},60*60*1000);maintenanceInterval.unref?.();
let backupTimer=null,backupInterval=null;if(BACKUPS_ENABLED){const backup=()=>createAutomaticWorldBackup().then(result=>console.log(`Shared world backup: ${result.destination}`)).catch(error=>console.warn(`Shared world backup warning: ${error.message}`));backupTimer=setTimeout(backup,5000);backupTimer.unref?.();backupInterval=setInterval(backup,24*60*60*1000);backupInterval.unref?.()}
return {server,realtimeHub,port:listening.port,host:HOST,dataDir:DATA_DIR,backupDir:BACKUP_DIR,backupTimer,backupInterval,maintenanceInterval,publicRoot:PUBLIC_ROOT,portFile:PUBLIC_BRIDGE_PORT_FILE,apacheBridge};
} catch (error) {
realtimeHub?.close();
realtimeHub=null;
if(server?.listening){server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))}
await fsp.unlink(PUBLIC_BRIDGE_PORT_FILE).catch(()=>{});
await releaseInstanceLock();
throw error;
}
}
async function closeApplication(result){
if(!result)return;
clearTimeout(result.backupTimer);clearInterval(result.backupInterval);clearInterval(result.maintenanceInterval);
try{result.realtimeHub?.close()}catch(error){console.warn(`Realtime shutdown warning: ${error.message}`)}
if(result.server?.listening)await new Promise(resolve=>result.server.close(()=>resolve()));
await fsp.unlink(result.portFile||PUBLIC_BRIDGE_PORT_FILE).catch(error=>{if(error?.code!=='ENOENT')console.warn(`Port file cleanup warning: ${error.message}`)});
await releaseInstanceLock();
}
module.exports=Object.freeze({normalizePlayerPurchases,sanitizeWorldGlobal,storeItem,collectRetiredBoardVersions,recoverPendingWorldCommit,commitWorldMutation,parsePort,defaultPortCandidates,listenWithPortFallback,createApplicationServer,closeApplication,main});
if(require.main===module){
let active=null,stopping=false;
const shutdown=async signal=>{
if(stopping)return;
stopping=true;
console.log(`LinkField received ${signal}; stopping.`);
await closeApplication(active);
process.exit(0);
};
main().then(result=>{
active=result;
process.once('SIGTERM',()=>shutdown('SIGTERM'));
process.once('SIGINT',()=>shutdown('SIGINT'));
}).catch(error=>{console.error(`LinkField server failed to start: ${error.message}`);if(process.env.LINK_FIELD_STARTUP_DEBUG==='1')console.error(error);process.exitCode=1});
}
async function main(){await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`);if(url.pathname.startsWith('/api/'))await handleApi(req,res,url);else await serveStatic(req,res,url)}catch(error){console.error(error);if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Internal server error',serverTime:serverTime()});else res.destroy()}});realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo});server.listen(PORT,HOST,()=>{console.log(`BEND FIELD v47.77 shared world: http://${HOST}:${PORT}`);console.log(`Shared world data: ${DATA_DIR}`)})}
main().catch(error=>{console.error(error);process.exitCode=1});

101
server/apache-bridge.js Normal file
View file

@ -0,0 +1,101 @@
'use strict';
const path = require('path');
const BEGIN_MARKER = '# BEGIN LINKFIELD MANAGED PROXY';
const END_MARKER = '# END LINKFIELD MANAGED PROXY';
function normalizePort(value) {
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Apache bridge port: ${value}`);
return port;
}
function renderPublicBoundary(){
return `\n`+
` # The service source may share this directory with Apache. Expose only\n`+
` # the curated browser bundle and deny every other direct file request.\n`+
` RewriteRule ^$ - [L]\n`+
` RewriteRule ^(?:index\\.html|style\\.css|favicon\\.(?:svg|ico)|build-meta\\.js|runtime-config\\.js|shared-contracts\\.js|store-catalog\\.(?:generated\\.js|json)|puzzle-patterns\\.js|puzzle-core\\.js|app-logic\\.js|archive-codec\\.js|field-persistence(?:-worker)?\\.js|puzzle-worker\\.js|app\\.js|api-bridge\\.php)$ - [L]\n`+
` RewriteRule ^(?:assets|client)(?:/|$) - [L]\n`+
` RewriteRule ^ - [F,L]\n`;
}
function renderSensitiveFileFallback(){
return `<FilesMatch "(?i)^(?:\\.|server\\.js$|realtime-server\\.js$|package(?:-lock)?\\.json$|build-config\\.json$|README\\.md$)">\n`+
` Require all denied\n`+
`</FilesMatch>\n`;
}
function renderApacheBridge(portValue) {
const port = normalizePort(portValue);
return `${BEGIN_MARKER}\n` +
`ServerSignature Off\n` +
`<IfModule mod_headers.c>\n` +
` Header always unset X-Powered-By\n` +
`</IfModule>\n` +
`<IfModule mod_rewrite.c>\n` +
` RewriteEngine On\n` +
`\n` +
` # Prefer a native Apache proxy when the host permits it.\n` +
` <IfModule mod_proxy.c>\n` +
` <IfModule mod_proxy_wstunnel.c>\n` +
` RewriteCond %{HTTP:Upgrade} =websocket [NC]\n` +
` RewriteRule ^api/realtime/?$ ws://127.0.0.1:${port}/api/realtime [P,L]\n` +
` </IfModule>\n` +
` <IfModule mod_proxy_http.c>\n` +
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\n` +
` </IfModule>\n` +
` </IfModule>\n` +
`\n` +
` # Shared hosts often disable mod_proxy. Route ordinary API requests\n` +
` # through the bundled PHP bridge instead.\n` +
` RewriteCond %{REQUEST_FILENAME} !-f\n` +
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n` +
renderPublicBoundary()+
`</IfModule>\n` +
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
function renderApacheBootstrap(){
return `${BEGIN_MARKER}\n`+
`ServerSignature Off\n`+
`<IfModule mod_headers.c>\n`+
` Header always unset X-Powered-By\n`+
`</IfModule>\n`+
`<IfModule mod_rewrite.c>\n`+
` RewriteEngine On\n\n`+
` # Safe bootstrap: use the bounded PHP bridge until the Node server\n`+
` # writes a verified current proxy port after it begins listening.\n`+
` RewriteCond %{REQUEST_FILENAME} !-f\n`+
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n`+
renderPublicBoundary()+
`</IfModule>\n`+
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
function replaceManagedBlock(existingValue, managedBlock) {
const existing = String(existingValue || '').replace(/\r\n?/g, '\n');
const pattern = new RegExp(`(?:^|\\n)${BEGIN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\n|$)`, 'g');
const preserved = existing.replace(pattern, '\n').replace(/^\n+|\n+$/g, '');
return preserved ? `${preserved}\n\n${managedBlock}` : managedBlock;
}
async function installApacheBridge({fsp, root, port, enabled = true} = {}) {
if (!enabled) return {enabled:false, written:false, file:null};
if (!fsp || typeof fsp.readFile !== 'function' || typeof fsp.writeFile !== 'function') throw new Error('A promises-compatible filesystem is required');
const file = path.join(root, '.htaccess');
let existing = '';
try { existing = await fsp.readFile(file, 'utf8'); }
catch (error) { if (error?.code !== 'ENOENT') throw error; }
const next = replaceManagedBlock(existing, renderApacheBridge(port));
if (next === existing.replace(/\r\n?/g, '\n')) return {enabled:true, written:false, file};
const temporary = `${file}.linkfield-${process.pid}-${Date.now()}.tmp`;
await fsp.writeFile(temporary, next, {encoding:'utf8', mode:0o644});
await fsp.rename(temporary, file);
return {enabled:true, written:true, file};
}
module.exports = Object.freeze({BEGIN_MARKER, END_MARKER, renderApacheBridge,renderApacheBootstrap,replaceManagedBlock,installApacheBridge});

20
server/auth.js Normal file
View file

@ -0,0 +1,20 @@
'use strict';
function createAuthenticator(options){
const {playerPattern,tokenPattern,readPlayer,hashToken,safeEqual}=options||{};
if(!(playerPattern instanceof RegExp)||!(tokenPattern instanceof RegExp))throw new TypeError('Authentication patterns are required');
if(typeof readPlayer!=='function'||typeof hashToken!=='function'||typeof safeEqual!=='function')throw new TypeError('Authentication ports are required');
const parse=req=>{
const raw=String(req?.headers?.authorization||''),match=/^Bearer\s+([^.]*)\.([^.]*)$/i.exec(raw);
if(!match||!playerPattern.test(match[1])||!tokenPattern.test(match[2]))throw Object.assign(new Error('Unauthorized'),{status:401});
return{playerId:match[1].toLowerCase(),token:match[2].toLowerCase()};
};
const player=async req=>{
const auth=parse(req),record=await readPlayer(auth.playerId);
if(!safeEqual(record.tokenHash,hashToken(auth.token)))throw Object.assign(new Error('Invalid cloud sync code'),{status:403});
return{...auth,record};
};
return Object.freeze({parse,player});
}
module.exports=Object.freeze({createAuthenticator});

23
server/http-router.js Normal file
View file

@ -0,0 +1,23 @@
'use strict';
function routeKey(method,pathname){return `${String(method||'GET').toUpperCase()} ${pathname}`}
function createHttpRouter({notFound}={}){
const routes=new Map();
const add=(method,pathname,handler)=>{
if(typeof handler!=='function')throw new TypeError('Route handler must be a function');
const key=routeKey(method,pathname);
if(routes.has(key))throw new Error(`Duplicate route: ${key}`);
routes.set(key,handler);return api;
};
const dispatch=async(req,res,url)=>{
const handler=routes.get(routeKey(req.method,url.pathname));
if(handler)return handler(req,res,url);
if(typeof notFound==='function')return notFound(req,res,url);
return false;
};
const api=Object.freeze({add,dispatch,has:(method,pathname)=>routes.has(routeKey(method,pathname)),routes:()=>Object.freeze([...routes.keys()])});
return api;
}
module.exports=Object.freeze({routeKey,createHttpRouter});

19
server/json-repository.js Normal file
View file

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

58
server/player-service.js Normal file
View file

@ -0,0 +1,58 @@
'use strict';
function createPlayerService(deps){
const {
randomHex,now,cleanName,defaultName,hashToken,writePlayer,readPlayer,
withPlayerQueue,withWorldQueue,readWorld,readWorldBoard,normalizeBonuses,
earnedScore,publicState,bonusAmount,bonusDelayMs,notifyProfile,
purchaseContext,findPurchase,assertAffordable,createPurchase,starterLineColor,boardPattern
}=deps||{};
const createSession=async nameInput=>{
const playerId=randomHex(12),token=randomHex(32),timestamp=now(),name=cleanName(nameInput,defaultName(playerId));
const record={
playerId,name,tokenHash:hashToken(token),purchases:[],generationBonuses:[],
starterLineColor:starterLineColor?.(playerId)||null,earnedScore:0,economyRevision:0,
createdAt:timestamp,updatedAt:timestamp
};
await writePlayer(record);
return{status:201,body:{playerId,token,name,starterLineColor:record.starterLineColor,revision:0,serverTime:timestamp}};
};
const updateProfile=async(playerId,nameInput)=>{
const name=cleanName(nameInput);if(!name)throw Object.assign(new Error('Player name is required'),{status:400});
return withPlayerQueue(playerId,async()=>{
const record=await readPlayer(playerId);record.name=name;record.updatedAt=now();await writePlayer(record);notifyProfile?.(playerId,name);
return{status:200,body:{playerId,name,serverTime:record.updatedAt}};
});
};
const getState=record=>({status:200,body:{player:publicState(record),serverTime:now()}});
const awardGenerationBonus=async(playerId,boardId)=>{
if(!boardPattern.test(boardId))throw Object.assign(new Error('Invalid board id'),{status:400});
return withPlayerQueue(playerId,()=>withWorldQueue(async()=>{
const record=await readPlayer(playerId),world=await readWorld(),row=await readWorldBoard(world,boardId),timestamp=now();
if(!row?.state?.solved||row.state.solvedById!==playerId)throw Object.assign(new Error('Only the solver can receive this bonus'),{status:403});
if(row.state.expanded===true)throw Object.assign(new Error('Expansion already succeeded'),{status:409});
if(timestamp-(Number(row.state.solvedAt)||0)<bonusDelayMs)throw Object.assign(new Error('Generation attempt is still in progress'),{status:409});
const grant=world.expansionGrants?.[playerId];
if(!grant||grant.boardId!==boardId||grant.expiresAt<timestamp||!(grant.maxBoards>0))throw Object.assign(new Error('No failed expansion is eligible'),{status:409});
record.generationBonuses=normalizeBonuses(record.generationBonuses);
if(!record.generationBonuses.includes(boardId)){
record.generationBonuses.push(boardId);
record.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore(record)+bonusAmount);
record.economyRevision=(record.economyRevision||0)+1;record.updatedAt=timestamp;await writePlayer(record);
}
return{status:200,body:{bonus:bonusAmount,player:publicState(record),serverTime:timestamp}};
}));
};
const purchase=async(playerId,boardId,itemId)=>withPlayerQueue(playerId,async()=>{
const record=await readPlayer(playerId),world=await readWorld(),context=await purchaseContext(world,boardId,itemId),existing=findPurchase(record,boardId,itemId);
if(existing)return{status:200,body:{purchase:existing,player:publicState(record),serverTime:now()}};
await assertAffordable(world,record,context.price);
const row=createPurchase(record,boardId,context.item,context.price);await writePlayer(record);
return{status:201,body:{purchase:row,player:publicState(record),serverTime:record.updatedAt}};
});
return Object.freeze({createSession,updateProfile,getState,awardGenerationBonus,purchase});
}
module.exports=Object.freeze({createPlayerService});

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

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

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

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

57
shared-contracts.js Normal file
View file

@ -0,0 +1,57 @@
'use strict';
(function attachSharedContracts(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
if(root)root.BendSharedContracts=api;
})(typeof globalThis!=='undefined'?globalThis:this,()=>{
const BOARD_ID_RE=/^B(?:0|[1-9][0-9]*)$/;
const CONTRACT_ID_RE=/^[A-Za-z0-9:_-]+$/;
const SPECIAL_MECHANIC_TYPES=Object.freeze(['warp','lock','crossing','internalGate']);
const SPECIAL_MECHANIC_SET=new Set(SPECIAL_MECHANIC_TYPES);
const DEFAULT_BUYER='\u65c5\u4eba';
function cleanContractId(value,maxLength=64){
const text=String(value||'').trim();
return text&&text.length<=maxLength&&CONTRACT_ID_RE.test(text)?text:'';
}
function cleanPlayerName(value,fallback=DEFAULT_BUYER){
const name=String(value||'').replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);
return name||fallback;
}
function normalizeSpecialMechanics(raw){
return[...new Set((Array.isArray(raw)?raw:[]).filter(type=>SPECIAL_MECHANIC_SET.has(type)))].sort();
}
function normalizePlayerPurchases(raw,{resolveItem=id=>({id}),maxPurchases=10000,maxPaidCost=Number.MAX_SAFE_INTEGER,defaultBuyer=DEFAULT_BUYER}={}){
const purchases=[],seenIds=new Set(),seenStoreItems=new Set();
for(const source of Array.isArray(raw)?raw:[]){
if(purchases.length>=maxPurchases)break;
const purchaseId=cleanContractId(source?.purchaseId,64),boardId=cleanContractId(source?.boardId,32),item=resolveItem(String(source?.itemId||'')),
storeKey=`${boardId}:${item?.id||''}`;
if(!purchaseId||!BOARD_ID_RE.test(boardId)||!item?.id||seenIds.has(purchaseId)||seenStoreItems.has(storeKey))continue;
const boughtAt=Number(source.boughtAt),paidCost=Number.isSafeInteger(source.paidCost)&&source.paidCost>=0?Math.min(source.paidCost,maxPaidCost):item.cost;
if(!Number.isSafeInteger(paidCost)||paidCost<0)continue;
seenIds.add(purchaseId);seenStoreItems.add(storeKey);
purchases.push({
purchaseId,
boardId,
itemId:item.id,
buyer:cleanPlayerName(source.buyer,defaultBuyer),
boughtAt:Number.isFinite(boughtAt)&&boughtAt>0?boughtAt:0,
paidCost
});
}
return purchases;
}
return Object.freeze({
BOARD_ID_RE,
SPECIAL_MECHANIC_TYPES,
cleanContractId,
cleanPlayerName,
normalizeSpecialMechanics,
normalizePlayerPurchases
});
});

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,145 @@
[
{
"id": "line-color-cyan",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#5fd8ff"
},
{
"id": "line-color-gold",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#ffd45f"
},
{
"id": "line-color-mint",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#72e38f"
},
{
"id": "line-color-violet",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#a98cff"
},
{
"id": "line-color-tangerine",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#ff915f"
},
{
"id": "line-color-cobalt",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#5f8dff"
},
{
"id": "line-color-coral",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#ff5f62"
},
{
"id": "line-color-aqua",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#42d6c4"
},
{
"id": "line-color-magenta",
"cost": 5000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#e66cff"
},
{
"id": "line-color-pearl",
"cost": 18000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#f4fbff"
},
{
"id": "line-color-lime",
"cost": 22000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#b8ff5f"
},
{
"id": "line-color-amber",
"cost": 26000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#ffb13b"
},
{
"id": "line-color-ice",
"cost": 36000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#b8f5ff"
},
{
"id": "line-color-lavender",
"cost": 42000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#d6b4ff"
},
{
"id": "line-effect-aurora",
"cost": 95000,
"cursorStyle": null,
"scoreLens": false,
"lineColor": "#5fd8ff",
"aurora": true
},
{
"id": "reaction-effect-giant",
"cost": 16000,
"cursorStyle": null,
"scoreLens": false,
"reactionStyle": "giant"
},
{
"id": "reaction-effect-laser",
"cost": 36000,
"cursorStyle": null,
"scoreLens": false,
"reactionStyle": "laser"
},
{
"id": "reaction-effect-orbit",
"cost": 44000,
"cursorStyle": null,
"scoreLens": false,
"reactionStyle": "orbit"
},
{
"id": "reaction-effect-firework",
"cost": 54000,
"cursorStyle": null,
"scoreLens": false,
"reactionStyle": "firework"
},
{
"id": "reaction-effect-comet",
"cost": 86000,
"cursorStyle": null,
"scoreLens": false,
"reactionStyle": "comet"
},
{
"id": "score-lens",
"cost": 200000,
@ -305,18 +446,6 @@
"cursorStyle": "emoji-1fae8",
"scoreLens": false
},
{
"id": "cursor-face-1f642-200d-2194-fe0f",
"cost": 44000,
"cursorStyle": "emoji-1f642-200d-2194-fe0f",
"scoreLens": false
},
{
"id": "cursor-face-1f642-200d-2195-fe0f",
"cost": 24500,
"cursorStyle": "emoji-1f642-200d-2195-fe0f",
"scoreLens": false
},
{
"id": "cursor-face-1f60c",
"cost": 5000,
@ -347,12 +476,6 @@
"cursorStyle": "emoji-1f634",
"scoreLens": false
},
{
"id": "cursor-face-1fae9",
"cost": 7500,
"cursorStyle": "emoji-1fae9",
"scoreLens": false
},
{
"id": "cursor-face-1f637",
"cost": 38000,
@ -491,12 +614,6 @@
"cursorStyle": "emoji-1f633",
"scoreLens": false
},
{
"id": "cursor-face-1faea",
"cost": 39500,
"cursorStyle": "emoji-1faea",
"scoreLens": false
},
{
"id": "cursor-face-1f97a",
"cost": 20000,

269
style.css
View file

@ -1,26 +1,8 @@
@font-face{font-family:"DotGothic16Local";src:url("assets/fonts/DotGothic16-Regular.ttf") format("truetype");font-style:normal;font-weight:400;font-display:swap}
:root{
--bg:#121416;
--panel:#1c1f22;
--ink:#f3f4f5;
--muted:#8b949b;
--grid:rgba(198,210,218,.28);
--accent:#d9f06f;
--bad:#ff6e7a;
--dot-font:"DotGothic16Local","DotGothic16","MS Gothic","MS ゴシック",ui-monospace,monospace;
--emoji-font:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif;
--bar-height:54px;
--safe-top:env(safe-area-inset-top,0px);
--safe-right:env(safe-area-inset-right,0px);
--safe-bottom:env(safe-area-inset-bottom,0px);
--safe-left:env(safe-area-inset-left,0px)
}
*{box-sizing:border-box;user-select:none;-webkit-user-select:none}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:var(--dot-font);font-variant-ligatures:none;-webkit-font-smoothing:none;text-rendering:geometricPrecision}
body,button,input,select,textarea{font-family:var(--dot-font)}
button,input,select,textarea{font-size:inherit}
button{font:inherit;color:inherit;letter-spacing:.04em}
button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-offset:3px}
@import url("client/styles/tokens.css") layer(tokens);
@import url("client/styles/base.css") layer(base);
@import url("client/ui/cursor.css") layer(cursor);
@import url("client/styles/accessibility.css") layer(accessibility);
@layer tokens,base,layout,board,interactions,hud,dialogs,cursor,responsive,accessibility,legacy;
#app{position:fixed;inset:0;overflow:hidden;background:radial-gradient(circle at 50% 45%,rgba(255,255,255,.025),transparent 34%),var(--bg)}
#topbar{
position:absolute;z-index:50;left:0;right:0;top:0;
@ -30,6 +12,7 @@ button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-off
background:linear-gradient(90deg,rgba(24,42,52,.985),rgba(31,35,58,.985));
border-bottom:1px solid rgba(126,213,224,.22)
}
.room-back-link{display:inline-flex;align-items:center;min-height:34px;padding:0 8px;border:1px solid rgba(201,156,255,.34);background:rgba(43,33,49,.72);color:#edc8ff;font-size:10px;font-weight:900;letter-spacing:.04em;text-decoration:none;white-space:nowrap}.room-back-link:hover,.room-back-link:focus-visible{border-color:#c99cff;background:#382942;color:#fff}
.brand{font-weight:900;letter-spacing:.08em;white-space:nowrap}
.brand small{font-weight:650;color:var(--muted);margin-left:8px}
.spacer{flex:1}
@ -68,7 +51,7 @@ button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-off
#viewport.panning{cursor:grabbing}
#world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
#overviewCanvas{position:absolute;left:0;top:0;display:block;pointer-events:none;z-index:3;transform-origin:0 0;will-change:transform;contain:strict}
#overviewCanvas{position:absolute;left:0;top:0;display:block;pointer-events:none;z-index:3;transform-origin:0 0;will-change:transform}
#overviewCanvas[hidden]{display:none}
#viewport.canvas-overview #world{display:none}
#minimap{
@ -91,11 +74,13 @@ button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-off
.board-card.solved{z-index:0;pointer-events:auto}
.board-card.solved:hover,.board-card.solved:focus,.board-card.solved:focus-within{z-index:2}
.board-svg{position:absolute;inset:0;display:block;overflow:visible;touch-action:none;pointer-events:none;cursor:crosshair}
.board-label{display:none;position:absolute;z-index:28;padding:3px 5px;border-radius:2px;background:rgba(12,14,16,.94);border:1px solid rgba(245,249,252,.62);border-left:3px solid var(--region);font-size:13px;letter-spacing:.05em;color:#eef2f4;pointer-events:auto;white-space:nowrap;box-shadow:0 3px 12px rgba(0,0,0,.28),0 0 0 1px rgba(255,255,255,.12),0 0 9px rgba(232,244,255,.16);text-shadow:0 1px 0 #000}
.board-card.hud-current{z-index:4}
.board-card.hud-current:not(.solved) .board-label{display:flex}
#boardHudLayer{position:absolute;z-index:36;inset:0;overflow:hidden;pointer-events:none}
.board-label{display:none;position:absolute;z-index:1;padding:4px 6px;border-radius:3px;background:rgba(8,11,13,.97);border:2px solid rgba(248,252,255,.96);border-left:5px solid var(--region);font-size:13px;letter-spacing:.05em;color:#eef2f4;pointer-events:auto;white-space:nowrap;box-shadow:0 5px 18px rgba(0,0,0,.58),0 0 0 2px rgba(8,11,13,.9),0 0 0 4px rgba(255,255,255,.24),0 0 16px rgba(232,244,255,.32);text-shadow:0 1px 0 #000}
.board-card.hud-current{z-index:40;contain:layout style}
.board-label.hud-visible:not([hidden]){display:flex}
.board-label[hidden]{display:none!important}
.board-label b{color:#f2f4f5;letter-spacing:.1em;margin:0}
.board-card.solved .static-layer,.board-card.solved .special-cell-layer,.board-card.solved .number-layer,.board-card.solved .gate-layer,.board-card.solved .board-label{display:none}
.board-card.solved .static-layer,.board-card.solved .special-cell-layer,.board-card.solved .number-layer,.board-card.solved .gate-layer{display:none}
.board-card.solved .board-svg{pointer-events:none;cursor:default}
.cell-shape{stroke:var(--grid);stroke-width:.8;vector-effect:non-scaling-stroke}
.outer-edge{stroke:rgba(220,229,234,.72);stroke-width:1.8;vector-effect:non-scaling-stroke}
@ -114,6 +99,9 @@ button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-off
.number-turn-warning.multiple .number-warning-caption{fill:#ffd5dc;font-size:7.1px}
.board-svg text,.tutorial-board text,svg text{font-family:var(--dot-font)}
.path{fill:none;stroke-width:var(--line-width,8);stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke;pointer-events:none}
#world{--aurora-rgb:95 216 255}
.path.line-effect-aurora,.gate-marker.line-effect-aurora,.gate-connect-pulse.line-effect-aurora,.endpoint-halo.line-effect-aurora{stroke:rgb(var(--aurora-rgb,95 216 255))!important;color:rgb(var(--aurora-rgb,95 216 255));transition:stroke 2s linear,color 2s linear}
.gate-dot.line-effect-aurora,.gate-knob.line-effect-aurora,.endpoint-knob.line-effect-aurora{fill:rgb(var(--aurora-rgb,95 216 255))!important;transition:fill 2s linear}
.path.invalid{stroke:#cfa2a7!important;stroke-dasharray:3 8;opacity:.58}
.board-card.solved .path{opacity:.9}
.endpoint-knob{stroke:#101214;stroke-width:2.8;vector-effect:non-scaling-stroke;pointer-events:none}
@ -155,11 +143,11 @@ button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-off
transform:translate(-50%,-50%);cursor:pointer;pointer-events:auto;transition:transform .14s ease,border-color .14s ease,box-shadow .14s ease
}
.line-store[hidden]{display:none}
.line-store .shop-shell,.static-shop-icon .shop-shell{position:relative;display:block;width:30px;height:24px;border:1.5px solid #d9f06f;border-radius:5px;background:linear-gradient(180deg,#263940,#152026);box-shadow:inset 0 1px rgba(255,255,255,.08)}
.line-store .shop-shell::before,.static-shop-icon .shop-shell::before{content:"";position:absolute;left:4px;right:4px;top:4px;height:5px;border-radius:2px;background:linear-gradient(90deg,#5fd8ff,#a98cff)}
.line-store .shop-shell::after,.static-shop-icon .shop-shell::after{content:"";position:absolute;left:3px;right:3px;top:11px;border-top:1px solid rgba(217,240,111,.65)}
.line-store .shop-shell i,.static-shop-icon .shop-shell i{position:absolute;left:6px;bottom:3px;width:6px;height:6px;border:1px solid #5fd8ff;border-radius:1px;background:rgba(95,216,255,.14)}
.line-store .shop-shell b,.static-shop-icon .shop-shell b{position:absolute;right:6px;bottom:0;width:7px;height:9px;border:1px solid #d9f06f;border-bottom:0;border-radius:2px 2px 0 0;background:#1c292e}
.line-store .shop-shell{position:relative;display:block;width:30px;height:24px;border:1.5px solid #d9f06f;border-radius:5px;background:linear-gradient(180deg,#263940,#152026);box-shadow:inset 0 1px rgba(255,255,255,.08)}
.line-store .shop-shell::before{content:"";position:absolute;left:4px;right:4px;top:4px;height:5px;border-radius:2px;background:linear-gradient(90deg,#5fd8ff,#a98cff)}
.line-store .shop-shell::after{content:"";position:absolute;left:3px;right:3px;top:11px;border-top:1px solid rgba(217,240,111,.65)}
.line-store .shop-shell i{position:absolute;left:6px;bottom:3px;width:6px;height:6px;border:1px solid #5fd8ff;border-radius:1px;background:rgba(95,216,255,.14)}
.line-store .shop-shell b{position:absolute;right:6px;bottom:0;width:7px;height:9px;border:1px solid #d9f06f;border-bottom:0;border-radius:2px 2px 0 0;background:#1c292e}
.line-store small{font-size:6px;font-weight:950;letter-spacing:.18em;color:#dfeaec}
.line-store:hover,.line-store:focus-visible{transform:translate(-50%,-50%) scale(1.06);border-color:#fff;box-shadow:0 10px 24px rgba(0,0,0,.5),0 0 0 3px rgba(95,216,255,.15)}
#toast{position:absolute;z-index:60;left:50%;bottom:calc(18px + var(--safe-bottom));max-width:min(92vw,720px);transform:translate(-50%,18px);opacity:0;padding:8px 10px;border-radius:2px;background:rgba(10,12,14,.92);border:1px solid rgba(255,255,255,.12);font-size:15px;pointer-events:none;transition:.2s ease;text-align:center}
@ -222,18 +210,18 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
.store-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;border-bottom:1px solid rgba(126,213,224,.22);padding-bottom:14px}
.store-heading small{color:#d9f06f;font-size:9px;font-weight:950;letter-spacing:.2em}
.store-heading h2{margin:4px 0 0}
.store-wallet{flex:0 0 auto;padding:9px 12px;border:1px solid rgba(194,108,255,.4);border-radius:2px;background:#2b2131;color:#d5b4e5;font-size:10px;font-weight:850;letter-spacing:.08em}
.store-wallet b{display:block;margin-top:2px;color:#edc8ff;font-size:18px;font-variant-numeric:tabular-nums}
.store-wallet{display:inline-flex;align-items:baseline;gap:5px;white-space:nowrap;flex:0 0 auto;padding:9px 12px;border:1px solid rgba(194,108,255,.4);border-radius:2px;background:#2b2131;color:#d5b4e5;font-size:10px;font-weight:850;letter-spacing:.08em}
.store-wallet b{display:inline;margin-top:0;color:#edc8ff;font-size:18px;font-variant-numeric:tabular-nums}
#storeMeta{margin:12px 0;color:#9ea8ad;font-size:11px;line-height:1.5}
.store-inventory{display:flex;flex-direction:column;gap:16px}
.store-section{display:flex;flex-direction:column;gap:8px}
.store-section-title{margin:0;padding:0 0 7px;border-bottom:1px solid rgba(255,255,255,.09);color:#d9f06f;font-size:11px;font-weight:950;letter-spacing:.16em}
.store-section-title{margin:0;padding:0 0 7px;border-bottom:1px solid rgba(255,255,255,.09);color:#d9f06f;font-size:11px;font-weight:950;letter-spacing:.16em}.store-section-brief{margin:0!important;color:#8f9aa0!important;font-size:10px!important;line-height:1.45!important}
.store-section-list{display:grid;gap:6px}.store-item-list{grid-template-columns:minmax(0,1fr)}
.store-cursor-list{grid-template-columns:repeat(6,minmax(86px,1fr))}
.store-compact-list{grid-template-columns:repeat(6,minmax(86px,1fr))}
.store-item{display:grid;grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px;border:1px solid rgba(255,255,255,.09);border-radius:2px;background:#181c1e}
.store-item.store-cursor{display:grid;grid-template-columns:1fr;grid-template-rows:auto auto;justify-items:stretch;gap:5px;padding:6px}
.store-item.store-cursor .store-item-icon{justify-self:center;width:52px;height:48px;font-size:31px}
.store-item.store-cursor .store-buy{min-width:0;width:100%;min-height:32px;padding:3px 2px;font-size:11px;line-height:1.15}
.store-item.store-compact{display:grid;grid-template-columns:1fr;grid-template-rows:auto auto;justify-items:stretch;gap:5px;padding:6px}
.store-item.store-compact .store-item-icon{justify-self:center;width:52px;height:48px;font-size:31px}
.store-item.store-compact .store-buy{grid-column:auto;min-width:0;width:100%;min-height:32px;padding:3px 2px;font-size:11px;line-height:1.15}
.store-item.purchased{border-color:rgba(217,240,111,.28);background:#1c211c}
.store-item-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:2px;background:#272d29;color:#d9f06f;font-size:25px}
.store-item-copy h4{margin:0 0 2px;font-size:13px;letter-spacing:.07em}
@ -248,7 +236,8 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
.inventory-heading>strong{padding:8px 10px;border:1px solid rgba(217,240,111,.25);background:#252b23;color:#d9f06f;font-size:9px;letter-spacing:.08em}
#inventoryTarget{margin:12px 0;color:#aeb6bb;font-size:10px;font-weight:900;letter-spacing:.08em}
.inventory-list{display:grid;gap:12px}
.inventory-section{display:grid;gap:6px}.inventory-section-title{margin:0;padding-bottom:5px;border-bottom:1px solid rgba(255,255,255,.09);color:#d9f06f;font-size:15px;letter-spacing:.12em}
.inventory-section{display:grid;gap:6px}.inventory-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0;padding:8px 10px;border:1px solid rgba(255,255,255,.09);background:#202528;color:#d9f06f;font-size:15px;font-weight:950;letter-spacing:.12em;cursor:pointer;list-style:none}.inventory-section-title::-webkit-details-marker{display:none}.inventory-section-title::before{content:'▾';font-size:12px;transition:transform .16s ease}.inventory-section:not([open]) .inventory-section-title::before{transform:rotate(-90deg)}.inventory-section-title span{margin-left:auto;min-width:28px;padding:2px 7px;border:1px solid rgba(217,240,111,.22);color:#dfe8b7;font-size:10px;text-align:center}.inventory-section[open]>.inventory-item-list,.inventory-section[open]>.inventory-cursor-grid{margin-top:2px}
.inventory-section[open]>.inventory-item-list,.inventory-section[open]>.inventory-cursor-grid{content-visibility:auto;contain-intrinsic-size:auto 420px}.inventory-item,.inventory-cursor-option{content-visibility:auto;contain-intrinsic-size:auto 52px}
.inventory-item-list{display:grid;gap:6px}
.inventory-cursor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(54px,1fr));gap:6px}
.inventory-cursor-option{display:grid;place-items:center;aspect-ratio:1;min-width:0;padding:3px;border:1px solid rgba(255,255,255,.1);border-radius:2px;background:#272d29;font-size:29px;cursor:pointer}
@ -258,7 +247,7 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
.inventory-item-icon{display:grid;place-items:center;width:40px;height:40px;background:#272d29;color:#d9f06f;font-size:22px}
.inventory-item h3{margin:0;font-size:12px;letter-spacing:.06em}.inventory-item p{margin:3px 0 0;color:#899399;font-size:9px;line-height:1.4}
.inventory-use{min-width:116px;min-height:39px;border:1px solid rgba(217,240,111,.34);background:#30392d;color:#eef8c8;font-size:9px;font-weight:950;cursor:pointer}
.inventory-use:hover:not(:disabled){background:#3a4734}.inventory-use:disabled{border-color:rgba(255,255,255,.08);background:#24282a;color:#737c81}
.inventory-use:hover:not(:disabled){background:#3a4734}.inventory-use.selected{border-color:#67efff;background:#153944;color:#bff9ff;box-shadow:inset 0 0 0 2px rgba(103,239,255,.17),0 0 12px rgba(103,239,255,.2)}.inventory-use:disabled{border-color:rgba(255,255,255,.08);background:#24282a;color:#737c81}
.time-attack-panel{width:min(760px,100%);padding:18px!important}
.time-attack-heading{display:flex;align-items:center;justify-content:space-between;gap:14px;padding-bottom:11px;border-bottom:1px solid rgba(126,213,224,.28)}
.time-attack-heading small{color:#d9f06f;font-size:12px;font-weight:950;letter-spacing:.16em}
@ -273,17 +262,17 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
.duration-value{grid-area:value;display:flex;align-items:flex-end;gap:3px;color:#eafa9d}.duration-value b{font-size:39px;line-height:.9}.duration-value small{font-size:15px;font-weight:950;line-height:1}
.time-attack-durations button>strong{grid-area:course;font-size:16px;letter-spacing:.08em;color:#f4f7f8}
.duration-status{grid-area:status;color:#b8c2c7;font-size:12px;font-style:normal;font-weight:850;letter-spacing:.04em}
.time-attack-rules{display:grid;grid-template-columns:minmax(150px,.8fr) minmax(0,2.2fr);align-items:center;gap:10px;margin-top:10px;padding:9px 11px;border:1px solid rgba(255,255,255,.1);background:#181c1e}
.time-attack-rules>div b{display:block;color:#eafa9d;font-size:15px}.time-attack-rules>div span{display:block;margin-top:3px;color:#aab4b9;font-size:12px;line-height:1.35}
.time-attack-rules{display:grid;grid-template-columns:minmax(150px,.8fr) minmax(0,2.2fr);align-items:center;gap:10px;margin-top:10px;padding:9px 11px;border:1px solid rgba(194,108,255,.42);background:linear-gradient(135deg,rgba(93,22,143,.24),rgba(43,33,49,.82))}
.time-attack-rules>div b{display:block;color:#edc8ff;font-size:15px}.time-attack-rules>div span{display:block;margin-top:3px;color:#d5b4e5;font-size:12px;line-height:1.35}
.time-attack-ladder{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
.time-attack-ladder span{display:grid;gap:1px;padding:6px 4px;border:1px solid rgba(255,255,255,.08);background:#202527;text-align:center}
.time-attack-ladder small{color:#9ca7ad;font-size:9px}.time-attack-ladder b{color:#fff;font-size:13px}.time-attack-ladder strong{color:#d9f06f;font-size:13px}
.time-attack-ladder small{color:#9ca7ad;font-size:9px}.time-attack-ladder b{color:#fff;font-size:13px}.time-attack-ladder strong{color:#e1b4ff;font-size:13px}
.time-attack-clock{margin:13px 0 9px;padding:13px;border:1px solid rgba(217,240,111,.4);background:repeating-linear-gradient(90deg,rgba(217,240,111,.025) 0 9px,transparent 9px 18px),#171b18;color:#f3ffbc;text-align:center;font-size:58px;font-weight:950;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:.08em}
.time-attack-clock.urgent{border-color:rgba(255,110,122,.65);color:#ff9ca5;animation:timeAttackUrgent .5s steps(2,end) infinite}
.time-attack-stats,.time-attack-result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}
.time-attack-stats span,.time-attack-result-grid span{padding:10px 11px;border:1px solid rgba(255,255,255,.1);background:#191d1f;color:#aeb8bd;font-size:12px;font-weight:900;letter-spacing:.04em}
.time-attack-stats b,.time-attack-result-grid b{display:block;margin-top:3px;color:#f4f6f7;font-size:21px;font-variant-numeric:tabular-nums}
.time-attack-stats span:nth-child(2) b,.time-attack-result-grid span:nth-child(4) b,.time-attack-result-grid span:nth-child(5) b,.time-attack-result-grid span:nth-child(6) b{color:#d9f06f}
.time-attack-stats span:nth-child(2) b,.time-attack-result-grid span:last-child b{color:#d9f06f}
#timeAttackShareText{min-height:96px;margin:8px 0 0;padding:11px 13px;border:1px dashed rgba(217,240,111,.28);background:#141719;color:#d2dadd;white-space:pre-wrap;user-select:text;-webkit-user-select:text;font:13px/1.6 var(--dot-font)}
.modal-actions{position:sticky;bottom:-22px;margin:10px -22px -22px;padding:12px 22px 18px;background:linear-gradient(transparent,#1d2124 24%)}
.entry-mode-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
@ -295,6 +284,16 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
#modal.entry-choice .panel{border-color:rgba(255,255,255,.22)}
.panel .close{width:100%}
.time-attack-actions{display:flex;gap:8px}.time-attack-actions .pill{flex:1;width:auto}
#timeAttackCountdownOverlay{position:fixed;z-index:420;inset:0;display:grid;place-items:center;pointer-events:none;background:transparent}
#timeAttackCountdownOverlay[hidden]{display:none}
#timeAttackCountdownOverlay.start-sequence{pointer-events:auto;background:rgba(5,7,9,.58)}
#timeAttackCountdownOverlay span{display:grid;place-items:center;min-width:1.5em;color:#fff;font-weight:950;font-variant-numeric:tabular-nums;line-height:1;text-align:center;text-shadow:0 7px 30px rgba(0,0,0,.72)}
#timeAttackCountdownOverlay.start-sequence span{font-size:clamp(120px,28vw,330px);color:#f3ffbc;animation:timeAttackCountdownStart .78s ease-out both}
#timeAttackCountdownOverlay.start-sequence.is-start span{font-size:clamp(74px,18vw,220px);letter-spacing:.04em;color:#eafa9d}
#timeAttackCountdownOverlay.final-sequence span{padding:.08em .2em;border-radius:12px;background:rgba(10,12,14,.34);color:#ffb2ba;font-size:clamp(74px,14vw,170px);animation:timeAttackCountdownFinal .5s ease-out both}
@keyframes timeAttackCountdownStart{0%{opacity:0;transform:scale(.62)}30%{opacity:1;transform:scale(1.08)}100%{opacity:.84;transform:scale(.94)}}
@keyframes timeAttackCountdownFinal{0%{opacity:0;transform:scale(.78)}28%{opacity:1;transform:scale(1.08)}100%{opacity:0;transform:scale(.96)}}
@keyframes timeAttackTick{50%{opacity:.45}}
@keyframes timeAttackUrgent{to{background-color:#28191c}}
@keyframes timeAttackStartEmphasis{0%,100%{transform:scale(1.18)}50%{transform:scale(1.3)}}
@ -308,6 +307,7 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
@media(max-width:700px){
:root{--bar-height:108px}
#topbar{align-content:center;align-items:center;flex-wrap:wrap;gap:4px 9px;padding-bottom:6px}
.room-back-link{min-height:30px;padding-inline:6px;font-size:9px}
.brand{font-size:13px}
.brand small,.stat.area{display:none}
.spacer{display:none}
@ -335,21 +335,6 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
.toolbar .pill{min-width:0;font-size:9px}
}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.001ms!important}
.board-card.revealing,.board-card.completing .path,.completion-burst,.gate-dot.frontier,.tutorial-demo *,.tutorial-demo::after,.num.turn-warning,.number-turn-warning,.pill.time-attack.active>span:first-child,.time-attack-clock.urgent,.completion-flash,.gate-connect-pulse,.number-match-orbit-holes,#viewport::before{animation:none!important}
.completion-burst{opacity:1}
.tutorial-path{stroke-dashoffset:0}
.tutorial-shop-marker,.tutorial-number-pulse{transform:none}
.tutorial-shop-score{opacity:1}
}
.cloud-status{min-width:72px;font-size:10px;font-weight:900;letter-spacing:.1em}
.cloud-status[data-state="saved"]{border-color:rgba(217,240,111,.42);color:var(--accent)}
.cloud-status[data-state="syncing"]{border-color:rgba(255,255,255,.36);animation:cloudPulse .8s steps(2,end) infinite}
.cloud-status[data-state="error"]{border-color:var(--bad);color:#ffd8dc}
.cloud-status[data-state="local"]{color:var(--muted)}
@keyframes cloudPulse{50%{opacity:.52}}
@media(max-width:700px){.toolbar .cloud-status{flex:0 1 62px;min-width:50px;padding:0 5px;font-size:8px}}
@media (max-width:560px){.entry-mode-actions{grid-template-columns:1fr}}
@ -464,15 +449,14 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
/* v47.13 interaction/reward pass */
.sr-data{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}
.compact-selected{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#timeAttackSuggestion{position:fixed;z-index:91;right:calc(18px + var(--safe-right));top:calc(66px + var(--safe-top));display:flex;align-items:center;gap:10px;max-width:min(520px,calc(100vw - 28px));padding:10px 12px;border:1px solid rgba(217,240,111,.62);border-radius:8px;background:rgba(14,20,22,.96);box-shadow:0 10px 34px rgba(0,0,0,.42);color:#eef4f5}
#timeAttackSuggestion[hidden]{display:none}#timeAttackSuggestion>div{display:grid;gap:2px}#timeAttackSuggestion b{color:#d9f06f;font-size:10px;letter-spacing:.12em}#timeAttackSuggestion span{font-size:11px;font-weight:800}#timeAttackSuggestion .quiet{min-width:30px;padding-inline:8px;opacity:.72}
.cell-confirm-flash{fill:rgba(255,255,255,.56);pointer-events:none}
@keyframes cellConfirmFlash{0%{fill:rgba(255,255,255,.45)}100%{fill:transparent}}
body[data-cursor-style="drop-cyan"] #viewport,body[data-cursor-style="drop-cyan"] .board-svg,body[data-cursor-style="drop-cyan"] body[data-cursor-style="drop-cyan"] .gate-hit,body[data-cursor-style="drop-cyan"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='4'%20x2='24'%20y2='24'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%2379f2df'%2F%3E%3Cstop%20offset='1'%20stop-color='%234aa7ff'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2'%20fill='%23101417'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="drop-rose"] #viewport,body[data-cursor-style="drop-rose"] .board-svg,body[data-cursor-style="drop-rose"] body[data-cursor-style="drop-rose"] .gate-hit,body[data-cursor-style="drop-rose"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='4'%20x2='24'%20y2='24'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23ffb0c9'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ff5f7f'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2'%20fill='%23101417'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="ring"] #viewport,body[data-cursor-style="ring"] .board-svg,body[data-cursor-style="ring"] body[data-cursor-style="ring"] .gate-hit,body[data-cursor-style="ring"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='none'%20stroke='white'%20stroke-width='2'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2.2'%20fill='%23d9f06f'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="comet"] #viewport,body[data-cursor-style="comet"] .board-svg,body[data-cursor-style="comet"] body[data-cursor-style="comet"] .gate-hit,body[data-cursor-style="comet"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='24'%20x2='22'%20y2='6'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%236b7cff'%20stop-opacity='.15'%2F%3E%3Cstop%20offset='1'%20stop-color='%23a98cff'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M5%2023L21%207'%20stroke='url(%23g)'%20stroke-width='4'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='21'%20cy='7'%20r='3.2'%20fill='white'%20stroke='%237c6cff'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 21 7,crosshair}
body[data-cursor-style="drop-cyan"] #viewport,body[data-cursor-style="drop-cyan"] .board-svg,body[data-cursor-style="drop-cyan"] .gate-hit,body[data-cursor-style="drop-cyan"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='4'%20x2='24'%20y2='24'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%2379f2df'%2F%3E%3Cstop%20offset='1'%20stop-color='%234aa7ff'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2'%20fill='%23101417'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="drop-rose"] #viewport,body[data-cursor-style="drop-rose"] .board-svg,body[data-cursor-style="drop-rose"] .gate-hit,body[data-cursor-style="drop-rose"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='4'%20x2='24'%20y2='24'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23ffb0c9'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ff5f7f'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2'%20fill='%23101417'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="ring"] #viewport,body[data-cursor-style="ring"] .board-svg,body[data-cursor-style="ring"] .gate-hit,body[data-cursor-style="ring"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Ccircle%20cx='14'%20cy='14'%20r='8.5'%20fill='none'%20stroke='white'%20stroke-width='2'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='2.2'%20fill='%23d9f06f'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="comet"] #viewport,body[data-cursor-style="comet"] .board-svg,body[data-cursor-style="comet"] .gate-hit,body[data-cursor-style="comet"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='4'%20y1='24'%20x2='22'%20y2='6'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%236b7cff'%20stop-opacity='.15'%2F%3E%3Cstop%20offset='1'%20stop-color='%23a98cff'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M5%2023L21%207'%20stroke='url(%23g)'%20stroke-width='4'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='21'%20cy='7'%20r='3.2'%20fill='white'%20stroke='%237c6cff'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 21 7,crosshair}
/* v47.14 radius items, expanded cursor catalog, and item debug mode */
@ -480,56 +464,39 @@ body[data-cursor-style="comet"] #viewport,body[data-cursor-style="comet"] .board
.debug-items-toggle input{width:18px;height:18px;accent-color:#d9f06f}.debug-items-toggle span{display:flex;align-items:baseline;gap:9px}.debug-items-toggle b{color:#d9f06f;font-size:10px;letter-spacing:.14em}.debug-items-toggle small{color:#96a2a8;font-size:9px;font-weight:800}
body[data-debug-items="on"] .debug-items-toggle{border-color:rgba(217,240,111,.55);box-shadow:0 0 18px rgba(217,240,111,.08)}
.inventory-item.debug-available{border-color:rgba(95,216,255,.2)}
body[data-cursor-style="cross-lime"] #viewport,body[data-cursor-style="cross-lime"] .board-svg,body[data-cursor-style="cross-lime"] body[data-cursor-style="cross-lime"] .gate-hit,body[data-cursor-style="cross-lime"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='29'%20height='29'%3E%3Cpath%20d='M14.5%204v6M14.5%2019v6M4%2014.5h6M19%2014.5h6'%20stroke='%23d9f06f'%20stroke-width='1.8'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='14.5'%20cy='14.5'%20r='3.1'%20fill='%2311161a'%20stroke='white'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="prism"] #viewport,body[data-cursor-style="prism"] .board-svg,body[data-cursor-style="prism"] body[data-cursor-style="prism"] .gate-hit,body[data-cursor-style="prism"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='6'%20y1='25'%20x2='19'%20y2='4'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%235fd8ff'%20stop-opacity='.35'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ffffff'%20stop-opacity='.95'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M7%2025L14%204l8%2017-8-3z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%20stroke-linejoin='round'%2F%3E%3C%2Fsvg%3E") 14 4,crosshair}
body[data-cursor-style="star-gold"] #viewport,body[data-cursor-style="star-gold"] .board-svg,body[data-cursor-style="star-gold"] body[data-cursor-style="star-gold"] .gate-hit,body[data-cursor-style="star-gold"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cpath%20d='M14%204v5M14%2019v5M4%2014h5M19%2014h5'%20stroke='%23ffd45f'%20stroke-width='2'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='4.2'%20fill='%23ffd45f'%20stroke='white'%20stroke-width='1.3'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='1.5'%20fill='%2317191b'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="diamond-violet"] #viewport,body[data-cursor-style="diamond-violet"] .board-svg,body[data-cursor-style="diamond-violet"] body[data-cursor-style="diamond-violet"] .gate-hit,body[data-cursor-style="diamond-violet"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='5'%20y1='5'%20x2='23'%20y2='23'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23d7c9ff'%2F%3E%3Cstop%20offset='1'%20stop-color='%237f64e8'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M14%204l10%2010-10%2010L4%2014z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='1.7'%20fill='%2317191b'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="orbit-blue"] #viewport,body[data-cursor-style="orbit-blue"] .board-svg,body[data-cursor-style="orbit-blue"] body[data-cursor-style="orbit-blue"] .gate-hit,body[data-cursor-style="orbit-blue"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='30'%20height='30'%3E%3Cellipse%20cx='15'%20cy='15'%20rx='11'%20ry='6.5'%20fill='none'%20stroke='%2372c8ff'%20stroke-width='1.8'%20transform='rotate(-28%2015%2015)'%2F%3E%3Ccircle%20cx='15'%20cy='15'%20r='3.2'%20fill='white'%20stroke='%234d82ff'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 15 15,crosshair}
body[data-cursor-style="needle"] #viewport,body[data-cursor-style="needle"] .board-svg,body[data-cursor-style="needle"] body[data-cursor-style="needle"] .gate-hit,body[data-cursor-style="needle"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cpath%20d='M5%204l15%2012-7%201-4%208z'%20fill='white'%20stroke='%2315191c'%20stroke-width='1.5'%20stroke-linejoin='round'%2F%3E%3Ccircle%20cx='5'%20cy='4'%20r='1.7'%20fill='%23d9f06f'%2F%3E%3C%2Fsvg%3E") 5 4,crosshair}
body[data-cursor-style="pixel-mint"] #viewport,body[data-cursor-style="pixel-mint"] .board-svg,body[data-cursor-style="pixel-mint"] body[data-cursor-style="pixel-mint"] .gate-hit,body[data-cursor-style="pixel-mint"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='26'%20height='26'%3E%3Crect%20x='6'%20y='6'%20width='14'%20height='14'%20rx='4'%20fill='%2372e3b4'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='13'%20cy='13'%20r='2'%20fill='%23112018'%2F%3E%3C%2Fsvg%3E") 13 13,crosshair}
body[data-cursor-style="flame-orange"] #viewport,body[data-cursor-style="flame-orange"] .board-svg,body[data-cursor-style="flame-orange"] body[data-cursor-style="flame-orange"] .gate-hit,body[data-cursor-style="flame-orange"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='8'%20y1='24'%20x2='18'%20y2='5'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23ff9d55'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ffe36c'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M7%2025L14%205l7%2016-7-3z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%20stroke-linejoin='round'%2F%3E%3C%2Fsvg%3E") 14 5,crosshair}
body[data-cursor-style="cross-lime"] #viewport,body[data-cursor-style="cross-lime"] .board-svg,body[data-cursor-style="cross-lime"] .board-input-surface,body[data-cursor-style="cross-lime"] .gate-hit,body[data-cursor-style="cross-lime"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='29'%20height='29'%3E%3Cpath%20d='M14.5%204v6M14.5%2019v6M4%2014.5h6M19%2014.5h6'%20stroke='%23d9f06f'%20stroke-width='1.8'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='14.5'%20cy='14.5'%20r='3.1'%20fill='%2311161a'%20stroke='white'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="prism"] #viewport,body[data-cursor-style="prism"] .board-svg,body[data-cursor-style="prism"] .board-input-surface,body[data-cursor-style="prism"] .gate-hit,body[data-cursor-style="prism"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='6'%20y1='25'%20x2='19'%20y2='4'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%235fd8ff'%20stop-opacity='.35'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ffffff'%20stop-opacity='.95'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M7%2025L14%204l8%2017-8-3z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%20stroke-linejoin='round'%2F%3E%3C%2Fsvg%3E") 14 4,crosshair}
body[data-cursor-style="star-gold"] #viewport,body[data-cursor-style="star-gold"] .board-svg,body[data-cursor-style="star-gold"] .board-input-surface,body[data-cursor-style="star-gold"] .gate-hit,body[data-cursor-style="star-gold"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cpath%20d='M14%204v5M14%2019v5M4%2014h5M19%2014h5'%20stroke='%23ffd45f'%20stroke-width='2'%20stroke-linecap='round'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='4.2'%20fill='%23ffd45f'%20stroke='white'%20stroke-width='1.3'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='1.5'%20fill='%2317191b'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="diamond-violet"] #viewport,body[data-cursor-style="diamond-violet"] .board-svg,body[data-cursor-style="diamond-violet"] .board-input-surface,body[data-cursor-style="diamond-violet"] .gate-hit,body[data-cursor-style="diamond-violet"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='28'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='5'%20y1='5'%20x2='23'%20y2='23'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23d7c9ff'%2F%3E%3Cstop%20offset='1'%20stop-color='%237f64e8'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M14%204l10%2010-10%2010L4%2014z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%2F%3E%3Ccircle%20cx='14'%20cy='14'%20r='1.7'%20fill='%2317191b'%2F%3E%3C%2Fsvg%3E") 14 14,crosshair}
body[data-cursor-style="orbit-blue"] #viewport,body[data-cursor-style="orbit-blue"] .board-svg,body[data-cursor-style="orbit-blue"] .board-input-surface,body[data-cursor-style="orbit-blue"] .gate-hit,body[data-cursor-style="orbit-blue"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='30'%20height='30'%3E%3Cellipse%20cx='15'%20cy='15'%20rx='11'%20ry='6.5'%20fill='none'%20stroke='%2372c8ff'%20stroke-width='1.8'%20transform='rotate(-28%2015%2015)'%2F%3E%3Ccircle%20cx='15'%20cy='15'%20r='3.2'%20fill='white'%20stroke='%234d82ff'%20stroke-width='1.4'%2F%3E%3C%2Fsvg%3E") 15 15,crosshair}
body[data-cursor-style="needle"] #viewport,body[data-cursor-style="needle"] .board-svg,body[data-cursor-style="needle"] .board-input-surface,body[data-cursor-style="needle"] .gate-hit,body[data-cursor-style="needle"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cpath%20d='M5%204l15%2012-7%201-4%208z'%20fill='white'%20stroke='%2315191c'%20stroke-width='1.5'%20stroke-linejoin='round'%2F%3E%3Ccircle%20cx='5'%20cy='4'%20r='1.7'%20fill='%23d9f06f'%2F%3E%3C%2Fsvg%3E") 5 4,crosshair}
body[data-cursor-style="pixel-mint"] #viewport,body[data-cursor-style="pixel-mint"] .board-svg,body[data-cursor-style="pixel-mint"] .board-input-surface,body[data-cursor-style="pixel-mint"] .gate-hit,body[data-cursor-style="pixel-mint"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='26'%20height='26'%3E%3Crect%20x='6'%20y='6'%20width='14'%20height='14'%20rx='4'%20fill='%2372e3b4'%20stroke='white'%20stroke-width='1.5'%2F%3E%3Ccircle%20cx='13'%20cy='13'%20r='2'%20fill='%23112018'%2F%3E%3C%2Fsvg%3E") 13 13,crosshair}
body[data-cursor-style="flame-orange"] #viewport,body[data-cursor-style="flame-orange"] .board-svg,body[data-cursor-style="flame-orange"] .board-input-surface,body[data-cursor-style="flame-orange"] .gate-hit,body[data-cursor-style="flame-orange"] .endpoint-hit{cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='28'%20height='30'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='8'%20y1='24'%20x2='18'%20y2='5'%20gradientUnits='userSpaceOnUse'%3E%3Cstop%20stop-color='%23ff9d55'%2F%3E%3Cstop%20offset='1'%20stop-color='%23ffe36c'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d='M7%2025L14%205l7%2016-7-3z'%20fill='url(%23g)'%20stroke='white'%20stroke-width='1.4'%20stroke-linejoin='round'%2F%3E%3C%2Fsvg%3E") 14 5,crosshair}
/* Emoji cursors replace the earlier geometric catalog while retaining saved style IDs. */
body[data-cursor-style="drop-cyan"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F642%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="drop-rose"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F604%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="ring"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F609%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="comet"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F60E%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="cross-lime"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F929%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="prism"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F60B%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="star-gold"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F7E1%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="diamond-violet"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x2B50%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="orbit-blue"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F315%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="needle"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x2600%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="pixel-mint"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F7E8%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="flame-orange"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F536%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-mode="dom"],body[data-cursor-mode="dom"] *{cursor:none!important}
body[data-cursor-mode="native"] :is(#viewport,.board-svg,.gate-hit,.endpoint-hit){cursor:var(--active-native-cursor,auto)!important}
.emoji-glyph,#customEmojiCursor{font-family:var(--emoji-font)!important;font-variant-emoji:emoji}
.item-flag-image{display:block;width:34px;height:26px;object-fit:contain;pointer-events:none}
#customEmojiCursor{position:fixed;z-index:300;left:0;top:0;display:none;width:32px;height:32px;place-items:center;overflow:visible;border:0;border-radius:0;background:transparent;font-size:26px;line-height:1;opacity:1;pointer-events:none;will-change:transform}
#customEmojiCursor.visible{display:grid}
#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden;border:2px solid #101214;border-radius:50%;background:#f4d45f;font-size:26px;opacity:1;box-shadow:0 2px 7px rgba(0,0,0,.5)}
#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;margin:0;transform:translate(-50%,-50%);border-radius:0;object-fit:cover;object-position:center;clip-path:none}
body[data-cursor-mode="native"] #customEmojiCursor{display:none!important}
#pickupHandleOverlay{position:fixed;z-index:299;left:0;top:0;display:none;width:15px;height:15px;border:2px solid #111820;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 0 0 2px rgba(255,255,255,.82),0 2px 7px rgba(0,0,0,.48);pointer-events:none;will-change:transform;contain:strict;transition:transform 16.67ms linear}
#pickupHandleOverlay.visible{display:block}
body[data-cursor-style="drop-cyan"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F642%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="drop-rose"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F604%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="ring"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F609%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="comet"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F60E%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="cross-lime"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F929%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="prism"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F60B%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="star-gold"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F7E1%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="diamond-violet"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x2B50%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="orbit-blue"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F315%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="needle"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x2600%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="pixel-mint"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F7E8%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
body[data-cursor-style="flame-orange"] :is(#viewport,.board-svg,.board-input-surface,.gate-hit,.endpoint-hit){cursor:url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='32'%20height='32'%3E%3Ctext%20x='2'%20y='27'%20font-size='26'%3E%26%23x1F536%3B%3C/text%3E%3C/svg%3E") 16 16,auto}
/* v47.36 uses a single route-free viewport canvas for distant world rendering. */
/* Nearby LOD keeps only board silhouettes and solved/partial route summaries. */
.board-card.board-static{z-index:1;overflow:hidden;pointer-events:none;opacity:.9}
.board-static-svg{position:absolute;inset:0;display:block;overflow:hidden;pointer-events:none}
.static-summary-fill{fill:transparent!important;stroke:rgba(225,237,243,.38);stroke-width:1.2;vector-effect:non-scaling-stroke;pointer-events:stroke}
.board-static.solved .static-summary-fill{fill:transparent!important;stroke:rgba(121,212,160,.42)}
.static-summary-path{fill:none;stroke-width:var(--static-line-width,3);stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke;opacity:.9;pointer-events:none}
.static-shop-icon{position:absolute;z-index:3;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:10px;background:rgba(12,18,22,.82);transform:translate(-50%,-50%) scale(var(--inverse-camera-scale));transform-origin:center;box-shadow:0 4px 7px rgba(0,0,0,.55);cursor:pointer;pointer-events:auto}
.drag-live-tail{pointer-events:none}
.drag-tip-group{pointer-events:none;will-change:transform;transform-origin:0 0}
body.lightweight-rendering .path{filter:none!important;mix-blend-mode:normal!important}
body.lightweight-rendering .path[class*="line-effect-"]{animation:none!important;stroke-dasharray:none!important}
.line-color-swatch{color:transparent!important;background:radial-gradient(circle at 35% 30%,#fff 0 8%,var(--item-color) 18% 62%,color-mix(in srgb,var(--item-color),#000 35%) 100%)!important;border:1px solid color-mix(in srgb,var(--item-color),#fff 35%)!important;box-shadow:0 0 12px color-mix(in srgb,var(--item-color),transparent 35%)}
@media (prefers-reduced-motion:reduce){.path[class*="line-effect-"]{animation:none!important}}
/* Performance modes keep input and camera movement ahead of decoration. */
.drag-layer{pointer-events:none}
@ -538,7 +505,6 @@ body.lightweight-rendering .path{filter:none!important;mix-blend-mode:normal!imp
.claim-pending-halo{fill:rgba(255,255,255,.08);stroke:rgba(255,255,255,.92);stroke-width:2;vector-effect:non-scaling-stroke}
.claim-pending-knob{fill:#f4d45f;stroke:#15191d;stroke-width:1.5;vector-effect:non-scaling-stroke}
.gate-knob.drag-target{opacity:1;transform:scale(1)}
#noiseCanvas.interaction-muted{opacity:.035}
body.reduced-effects #noiseCanvas,body.effects-paused #noiseCanvas{opacity:.045;transition:none}
#topbar.drawing-active .brand,
#topbar.drawing-active .stat:not(.score),
@ -550,10 +516,10 @@ body.reduced-effects #noiseCanvas,body.effects-paused #noiseCanvas{opacity:.045;
.board-card.input-active .path-layer,
.board-card.input-active .gate-layer,
.board-card.input-active .drag-layer{opacity:1;filter:none}
:is(#world.camera-interacting,.board-card.input-active) .gate-dot.frontier,
:is(#world.camera-interacting,.board-card.input-active) .num.turn-warning,
:is(#world.camera-interacting,.board-card.input-active) .number-turn-warning.show,
:is(#world.camera-interacting,.board-card.input-active) .unfilled-warning-cells,
.board-card.input-active .gate-dot.frontier,
.board-card.input-active .num.turn-warning,
.board-card.input-active .number-turn-warning.show,
.board-card.input-active .unfilled-warning-cells,
body.reduced-effects .gate-dot.frontier,
body.reduced-effects .num.turn-warning,
body.reduced-effects .number-turn-warning.show,
@ -561,8 +527,7 @@ body.reduced-effects .unfilled-warning-cells{animation-play-state:paused!importa
@media(prefers-reduced-motion:reduce){
#topbar .brand,#topbar .stat,#topbar .toolbar>*,#saveStatus{transition:none}
}
:is(#world.camera-interacting,.board-card.input-active) .special-cell,
#world.camera-interacting .overview-fill,
.board-card.input-active .special-cell,
body.reduced-effects .special-cell,
body.reduced-effects .overview-fill{filter:none!important}
body.effects-paused *{animation-play-state:paused!important}
@ -578,16 +543,16 @@ body.effects-paused *{animation-play-state:paused!important}
.store-wallet{padding:5px 7px;font-size:12px}.store-wallet b{font-size:20px}#storeMeta,#inventoryTarget{margin:5px 0;font-size:13px}
.store-inventory{gap:10px}.store-section{gap:5px}.store-section-title{padding-bottom:4px;font-size:15px}.store-item{gap:7px;padding:6px}
.store-item-copy h4{font-size:16px}.store-item-copy strong{font-size:12px}.store-item-copy p{font-size:12px}.store-buy{min-height:34px;padding:4px 6px;font-size:13px}
.store-item.store-cursor .store-buy{font-size:12px}.inventory-list{gap:9px}.inventory-item{gap:7px;padding:6px}.inventory-item h3{font-size:15px}.inventory-item p{font-size:12px}.inventory-use{min-height:34px;font-size:12px}
.store-item.store-compact .store-buy{font-size:12px}.inventory-list{gap:9px}.inventory-item{gap:7px;padding:6px}.inventory-item h3{font-size:15px}.inventory-item p{font-size:12px}.inventory-use{min-height:34px;font-size:12px}
.inventory-heading>strong{padding:5px 7px;font-size:11px}.inventory-cursor-option{font-size:30px}
.minimap-head{font-size:12px}.minimap-legend{font-size:9px}.minimap-controls button{font-size:9px}.modal-actions{margin:5px -8px -8px;padding:7px 8px 9px}
@media(max-width:700px){
.store-cursor-list{grid-template-columns:repeat(4,minmax(66px,1fr))}
.store-item.store-cursor .store-buy{grid-column:auto}
.store-compact-list{grid-template-columns:repeat(4,minmax(66px,1fr))}
.store-item.store-compact .store-buy{grid-column:auto}
}
@media(max-width:420px){
.store-cursor-list{grid-template-columns:repeat(3,minmax(62px,1fr))}
.store-compact-list{grid-template-columns:repeat(3,minmax(62px,1fr))}
}
/* Shared-world phase 1: compact clear notifications above the minimap. */
@ -605,11 +570,10 @@ body.effects-paused *{animation-play-state:paused!important}
#presenceCanvas{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;z-index:25}
.board-claim-badge{position:absolute;z-index:31;left:50%;top:8px;transform:translateX(-50%);max-width:calc(100% - 24px);padding:4px 7px;border:1px solid rgba(255,205,105,.65);background:rgba(19,16,12,.94);color:#ffe2a0;font:10px/1.2 var(--dot-font);font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;box-shadow:0 3px 12px rgba(0,0,0,.36)}
.board-claim-badge[hidden]{display:none}
.board-card.claimed-other:not(.solved) .board-card.claimed-other:not(.solved) .gate-hit,.board-card.claimed-other:not(.solved) .endpoint-hit{cursor:not-allowed}
.board-card.claimed-other:not(.solved) .gate-hit,.board-card.claimed-other:not(.solved) .endpoint-hit{cursor:not-allowed}
.board-card.claimed-other:not(.solved) .static-layer,.board-card.claimed-other:not(.solved) .special-cell-layer,.board-card.claimed-other:not(.solved) .number-layer,.board-card.claimed-other:not(.solved) .gate-layer{opacity:.68}
.board-card.claimed-other:not(.solved)::after{content:"";position:absolute;inset:var(--claim-inset,26px);z-index:29;border:2px dashed rgba(255,205,105,.5);background:rgba(28,20,8,.08);pointer-events:none}
.board-card.claimed-own:not(.solved) .board-label{border-color:rgba(217,240,111,.7)}
.board-static.claimed-other:not(.solved)::before,.board-static.claimed-own:not(.solved)::before{content:attr(data-claim-label);position:absolute;z-index:31;left:50%;top:8px;transform:translateX(-50%) scale(var(--inverse-camera-scale));transform-origin:top center;max-width:calc(100% - 24px);padding:4px 7px;border:1px solid rgba(255,205,105,.65);background:rgba(19,16,12,.94);color:#ffe2a0;font:10px/1.2 var(--dot-font);font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;box-shadow:0 3px 12px rgba(0,0,0,.36)}
.board-label.claimed-own{border-color:#f4ffad;box-shadow:0 5px 18px rgba(0,0,0,.58),0 0 0 2px rgba(8,11,13,.92),0 0 0 4px rgba(217,240,111,.35),0 0 18px rgba(217,240,111,.4)}
@media(max-width:640px){.board-claim-badge{font-size:8px;padding:3px 5px}}
@ -622,11 +586,16 @@ body.effects-paused *{animation-play-state:paused!important}
body.reaction-selecting #viewport{cursor:none!important}
/* v47.71 HUD, input, cursor, and time-attack refinements */
.shared-indicator{position:fixed;z-index:46;left:calc(6px + var(--safe-left));bottom:calc(16px + var(--safe-bottom));display:inline-flex;align-items:center;gap:3px;width:31px;height:7px;color:rgba(200,210,214,.58);font-size:6px;line-height:1;font-weight:800;letter-spacing:.04em;white-space:nowrap;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.72)}
.shared-indicator i{display:block;flex:0 0 4px;width:4px;height:4px;border-radius:50%;background:#849097;box-shadow:0 0 3px rgba(132,144,151,.5)}
.shared-indicator[data-state="online"] i{background:#d9f06f;box-shadow:0 0 4px rgba(217,240,111,.72)}
.shared-indicator[data-state="syncing"] i,.shared-indicator[data-state="connecting"] i{background:#ffd46f;box-shadow:0 0 4px rgba(255,212,111,.68)}
.shared-indicator[data-state="offline"] i{background:#ff7c88;box-shadow:0 0 4px rgba(255,124,136,.7)}
.fps-stat{position:fixed;z-index:46;left:calc(6px + var(--safe-left));bottom:calc(5px + var(--safe-bottom));min-width:0;padding:0;border:0;background:none;box-shadow:none;color:rgba(200,210,214,.48);font-size:8px;line-height:1;font-variant-numeric:tabular-nums;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.72)}
.fps-stat b{color:inherit;letter-spacing:.02em;font-weight:700}
.unfilled-warning-cells{fill:#3b3430;stroke:#bda98b;stroke-width:1.4;vector-effect:non-scaling-stroke;pointer-events:none}
.board-input-surface{outline:none}
:is(#world.camera-interacting,.board-card.input-active) .unfilled-warning-cells,body.reduced-effects .unfilled-warning-cells{filter:none!important}
.board-card.input-active .unfilled-warning-cells,body.reduced-effects .unfilled-warning-cells{filter:none!important}
#fpsCounter[data-fps=idle]{color:#aab4b9}#fpsCounter[data-fps=good]{color:#d9f06f}#fpsCounter[data-fps=ok]{color:#ffd46f}#fpsCounter[data-fps=low]{color:#ff7c88}
@ -665,21 +634,53 @@ body.lightweight-rendering *{transition-duration:0s!important}
.board-card{isolation:auto}
.board-svg{contain:layout style paint;backface-visibility:hidden}
#presenceCanvas,#reactionCanvas{transform-origin:0 0;will-change:transform;contain:strict}
#viewport.panning::after{display:none}
#viewport.panning .board-label,
#viewport.panning .solver-badge,
#viewport.panning .claim-badge,
#viewport.panning .score-lens-badge,
#viewport.panning .line-store{visibility:hidden!important}
#viewport.panning .board-svg{shape-rendering:optimizeSpeed}
:is(#world.camera-interacting,.board-card.input-active) .number-match-orbit-holes,
:is(#world.camera-interacting,.board-card.input-active) .number-match-pulse,
:is(#world.camera-interacting,.board-card.input-active) .cell-confirm-flash{animation:none!important}
:is(#world.camera-interacting,.board-card.input-active) .gate-knob,
:is(#world.camera-interacting,.board-card.input-active) .solver-badge,
:is(#world.camera-interacting,.board-card.input-active) .line-store{transition:none!important}
.board-card.input-active .number-match-orbit-holes,
.board-card.input-active .number-match-pulse,
.board-card.input-active .cell-confirm-flash{animation:none!important}
.board-card.input-active .gate-knob,
.board-card.input-active .solver-badge,
.board-card.input-active .line-store{transition:none!important}
body.lightweight-rendering .board-label,
body.lightweight-rendering .solver-badge,
body.lightweight-rendering .claim-badge,
body.lightweight-rendering .line-store,
body.lightweight-rendering .score-lens-badge{box-shadow:none!important;text-shadow:none!important}
/* v47.79: fixed active-board HUD and settings actions. */
#boardHudLayer .board-label{max-width:calc(100% - 16px);align-items:center;gap:8px;min-height:32px;padding:4px 7px;font-size:14px;line-height:1.15;overflow:visible;contain:layout style;pointer-events:auto}
#boardHudLayer .board-label>b{flex:0 0 auto}
#boardHudLayer .board-actions{flex:0 0 auto}
.settings-actions{justify-content:flex-end}.settings-actions #resetSettings{margin-right:4px}
/* v47.83: aligned settings actions and cyber-pop stores. */
.settings-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}
.settings-actions .pill{flex:1 1 0;width:auto;margin:0!important}
.line-store{width:62px;height:58px;padding:5px 5px 4px;border:1px solid rgba(92,242,255,.86);border-radius:4px;background:linear-gradient(145deg,rgba(26,15,45,.98),rgba(8,30,43,.98));box-shadow:0 10px 26px rgba(0,0,0,.5),0 0 0 2px rgba(255,62,190,.2),0 0 18px rgba(50,226,255,.28);clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px)}
.line-store .shop-shell{position:relative;display:block;width:40px;height:31px;border:1.5px solid #60f2ff;border-radius:2px;background:linear-gradient(135deg,#35164c 0 44%,#102f45 45% 100%);box-shadow:inset 0 0 0 2px rgba(255,255,255,.06),0 0 10px rgba(89,239,255,.38);clip-path:polygon(6px 0,100% 0,100% 25px,34px 31px,0 31px,0 6px)}
.line-store .shop-shell::before{content:"";position:absolute;left:3px;right:3px;top:4px;height:7px;border-radius:0;background:repeating-linear-gradient(90deg,#ff45c5 0 6px,#ffe75f 6px 11px,#5ff3ff 11px 17px);box-shadow:0 0 7px rgba(255,69,197,.6)}
.line-store .shop-shell::after{content:"";position:absolute;left:4px;right:4px;top:14px;border-top:1px solid rgba(95,243,255,.76);box-shadow:0 4px 0 rgba(255,69,197,.42)}
.line-store .shop-shell i{position:absolute;left:6px;bottom:4px;width:9px;height:8px;border:1px solid #ff54ce;border-radius:0;background:rgba(255,84,206,.18);box-shadow:0 0 5px rgba(255,84,206,.55)}
.line-store .shop-shell b{position:absolute;right:7px;bottom:0;width:9px;height:12px;border:1px solid #ffe96b;border-bottom:0;border-radius:1px 1px 0 0;background:#172636}
.line-store .shop-shell em{position:absolute;left:18px;bottom:5px;width:5px;height:5px;border-radius:50%;background:#65f5ff;box-shadow:0 0 8px #65f5ff}
.line-store small{font-size:6px;font-weight:950;letter-spacing:.11em;color:#f7f8ff;text-shadow:0 0 6px #ff4dcc}
.line-store:hover,.line-store:focus-visible{transform:translate(-50%,-50%) scale(1.08);border-color:#fff;box-shadow:0 12px 30px rgba(0,0,0,.56),0 0 0 3px rgba(255,67,197,.25),0 0 24px rgba(73,237,255,.52)}
.minimap-legend .shop::before{width:7px;height:7px;background:linear-gradient(135deg,#ff45c5 0 50%,#5ff3ff 50%);box-shadow:0 0 4px rgba(95,243,255,.7)}
.special-internal-gate .special-cell-frame{fill:rgba(61,20,83,.28);stroke:#ef63ff;stroke-dasharray:3 3}
.internal-gate-bracket{fill:none;stroke:#61f3ff;stroke-width:2.4;stroke-linecap:square;vector-effect:non-scaling-stroke}
.gate-marker.internal{stroke:#ff6ed7;stroke-width:2.8}
.gate-dot.internal{stroke:#5ff3ff;stroke-width:2.5;filter:drop-shadow(0 0 4px rgba(95,243,255,.75))}
.purchase-item-fly{position:fixed;z-index:420;display:grid;place-items:center;width:48px;height:48px;border:1px solid rgba(255,255,255,.78);border-radius:8px;background:linear-gradient(145deg,rgba(38,45,41,.98),rgba(13,18,21,.98));box-shadow:0 0 0 3px rgba(95,216,255,.16),0 0 24px rgba(95,216,255,.72),0 12px 38px rgba(0,0,0,.62);color:#d9f06f;font-size:27px;pointer-events:none;will-change:transform,opacity}
.purchase-item-fly.line-color-swatch::before{content:"";width:28px;height:7px;border-radius:999px;background:var(--item-color);box-shadow:0 0 11px var(--item-color)}
.purchase-item-fly .item-flag-image{max-width:34px;max-height:25px}
.inventory-button.purchase-arrival{animation:inventoryPurchaseArrival .4s cubic-bezier(.15,.8,.2,1)}
@keyframes inventoryPurchaseArrival{0%{transform:scale(1)}36%{transform:scale(1.15);box-shadow:0 0 0 4px rgba(217,240,111,.18),0 0 28px rgba(95,216,255,.74)}100%{transform:scale(1)}}
.line-color-swatch.aurora-swatch{background:conic-gradient(from 25deg,#64f4ff,#a47bff,#ff70cf,#ffd86a,#72f2a3,#64f4ff)!important;border-color:rgba(255,255,255,.72)!important;box-shadow:0 0 16px rgba(112,228,255,.55),0 0 22px rgba(255,112,207,.28)!important}
.pill.time-attack.active.final-countdown{transform:scale(1.12);font-size:1.08em;box-shadow:inset 0 -2px #d9f06f,0 0 0 3px rgba(217,240,111,.24),0 8px 28px rgba(0,0,0,.38);animation:timeAttackFinalTimerPulse 1s ease-in-out infinite}
@keyframes timeAttackFinalTimerPulse{0%,100%{transform:scale(1.1)}50%{transform:scale(1.18)}}

View file

@ -0,0 +1,51 @@
'use strict';
const {assert,read,functionSource}=require('./helpers/app-source');
const {createHttpRouter}=require('../server/http-router');
const {createAuthenticator}=require('../server/auth');
const {createJsonRepository}=require('../server/json-repository');
const {createCursorModel}=require('../client/ui/cursor');
(async()=>{
const calls=[],router=createHttpRouter({notFound:()=>calls.push('missing')});
router.add('GET','/ok',(_req,_res,url)=>calls.push(url.pathname));
await router.dispatch({method:'GET'},null,{pathname:'/ok'});
await router.dispatch({method:'POST'},null,{pathname:'/ok'});
assert(calls.join(',')==='/ok,missing'&&router.routes().length===1,'HTTP route dispatch does not isolate method/path selection');
const auth=createAuthenticator({
playerPattern:/^[a-f0-9]{16,64}$/i,
tokenPattern:/^[a-f0-9]{32,128}$/i,
readPlayer:async()=>({tokenHash:'aa'}),
hashToken:()=> 'aa',
safeEqual:(a,b)=>a===b
});
const request={headers:{authorization:`Bearer ${'a'.repeat(16)}.${'b'.repeat(32)}`}};
assert((await auth.player(request)).playerId==='a'.repeat(16),'Authentication middleware rejected a valid injected repository result');
let unauthorized=false;try{auth.parse({headers:{}})}catch(error){unauthorized=error.status===401}
assert(unauthorized,'Authentication middleware did not reject a missing bearer token');
let renameAttempts=0;const files=new Map(),fsp={
async readFile(file){const value=files.get(file);if(value==null)throw Object.assign(new Error('missing'),{code:'ENOENT'});return value},
async writeFile(file,value){files.set(file,value)},
async rename(from,to){if(++renameAttempts<3)throw Object.assign(new Error('busy'),{code:'EPERM'});files.set(to,files.get(from));files.delete(from)},
async unlink(file){if(!files.delete(file))throw Object.assign(new Error('missing'),{code:'ENOENT'})}
};
const repository=createJsonRepository({fsp,crypto:{randomBytes:()=>Buffer.from('abcdef','hex')},processId:1});
await repository.write('world.json',{revision:3});
assert((await repository.read('world.json')).revision===3&&renameAttempts===3,'JSON repository did not retry and publish a transiently blocked atomic record');
await repository.remove('world.json');assert(await repository.read('world.json',{missing:null})===null,'JSON repository missing-value behavior is incorrect');
const cursor=createCursorModel([{cursorStyle:'smile',cursorEmoji:'🙂'},{cursorStyle:'flag',flagAsset:'flag.svg'}]);
assert(cursor.presentation('smile').mode==='dom'&&cursor.presentation('smile').pickup.kind==='glyph','Cursor model did not map glyph presentation');
assert(cursor.presentation('flag').pickup.asset==='flag.svg'&&cursor.presentation('missing').mode==='default','Cursor model did not map flag or default presentation');
const server=read('server.js'),style=read('style.css'),html=read('index.html');
assert(functionSource('handleApi',server).includes('apiRouter.dispatch')&&functionSource('handleApi',server).length<120,'Server route selection is still coupled to domain behavior');
assert(server.includes("require('./server/player-service')")&&server.includes("require('./server/json-repository')"),'Server services or repositories are not wired through explicit boundaries');
assert(style.startsWith('@import url("client/styles/tokens.css") layer(tokens);')&&style.includes('@import url("client/styles/base.css") layer(base);')&&style.includes('@import url("client/ui/cursor.css") layer(cursor);')&&style.includes('@layer tokens,base,layout,board,interactions,hud,dialogs,cursor,responsive,accessibility,legacy;'),'CSS cascade ownership is not explicit');
assert(!style.includes('.board-card.claimed-other:not(.solved) .board-card.claimed-other'),'Unmatchable nested claimed-board selector remains');
assert(html.indexOf('client/ui/cursor.js')<html.indexOf('app.js'),'Cursor model is not loaded before the application');
console.log('HTTP, authentication, repository, cursor, and CSS ownership boundaries passed');
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -13,7 +13,7 @@ const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_ED
);
const requested=(process.env.BEND_FIELD_SCALE_SIZES||'10000,100000,200000').split(',').map(Number).filter(value=>Number.isSafeInteger(value)&&value>0&&value<=200000);
if(!requested.length)throw new Error('BEND_FIELD_SCALE_SIZES did not contain a supported board count.');
const worldDbName='bend-field:v30:v47-field-reset-20260728-interaction-fix:world',explicitBenchmarkUrl=process.env.BEND_FIELD_BENCHMARK_URL||'',benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost',useExtension=process.env.BEND_FIELD_BENCHMARK_EXTENSION==='1';
const worldDbName='bend-field:v30:linkfield-single-world-20260801:world',explicitBenchmarkUrl=process.env.BEND_FIELD_BENCHMARK_URL||'',benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost',useExtension=process.env.BEND_FIELD_BENCHMARK_EXTENSION==='1';
const debuggingPort=22000+Math.floor(Math.random()*1000),temporaryRoot=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-v2-scale-')),profilePath=path.join(temporaryRoot,'browser-profile');
const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
function stopBrowserTree(child){

View file

@ -12,9 +12,12 @@ const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds))
let serverPort=0;
const debuggingPort=20000+Math.floor(Math.random()*1000),benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost';
const startupOnly=process.env.BEND_FIELD_STARTUP_ONLY==='1';
const effectVisualFixture=JSON.parse(fs.readFileSync(path.join(root,'test','fixtures','effect-visual-checkpoints.json'),'utf8'));
const benchmarkOutputPath=process.env.BEND_FIELD_BENCHMARK_OUTPUT||path.join(root,'test-results','browser-performance-benchmark.json');
function writeBenchmarkReport(report){fs.mkdirSync(path.dirname(benchmarkOutputPath),{recursive:true});fs.writeFileSync(benchmarkOutputPath,`${JSON.stringify(report,null,2)}\n`)}
const temporaryRoot=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-browser-benchmark-'));
const profilePath=path.join(temporaryRoot,'edge-profile');
const worldDbName='bend-field:v30:v47-field-reset-20260728-interaction-fix:world';
const worldDbName='bend-field:v30:linkfield-single-world-20260801:world';
const allProfiles=[
{name:'small',boards:16,mode:'grid'},
{name:'medium',boards:128,mode:'grid'},
@ -84,23 +87,10 @@ async function endpoint(){
}
async function startStaticServer(){
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
const server=http.createServer((request,response)=>{
const pathname=new URL(request.url,'http://localhost').pathname;
const relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1));
const file=path.resolve(root,relative);
if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}
fs.readFile(file,(error,body)=>{
if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}
response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});
response.end(body);
});
});
await new Promise((resolve,reject)=>{
server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()});
});
serverPort=server.address().port;
return server;
const publicDir=path.join(temporaryRoot,'public'),dataRoot=path.join(temporaryRoot,'server-data');
process.env.HOST='127.0.0.1';process.env.PORT='0';process.env.LINK_FIELD_TEST_DATA_ROOT=dataRoot;process.env.LINK_FIELD_PUBLIC_DIR=publicDir;process.env.LINK_FIELD_APACHE_BRIDGE='0';
const service=require('../scripts/service-control');await service.deployPublicFiles(publicDir);
const application=require('../server'),result=await application.main(),server=result.server;serverPort=result.port;server._linkfieldClose=()=>application.closeApplication(result);return server;
}
async function ready(client){
@ -278,10 +268,10 @@ async function panCadenceProbe(client,steps=24){
const viewport=document.querySelector('#viewport'),rect=viewport.getBoundingClientRect(),pointerId=91,startX=rect.left+rect.width*.7,startY=rect.top+rect.height*.6;
const [worldX,worldY]=worldUnitAtClient(startX,startY),reactionNow=Date.now(),remoteId='benchmark-remote';
applyRemotePlayer({presenceId:remoteId,playerId:'benchmark',name:'Remote',cursorStyle:'default',x:worldX,y:worldY,vx:.08,vy:.03,sentAt:reactionNow});
applyRealtimeReaction({id:'benchmark-reaction',emoji:REACTION_EMOJIS[0],x:worldX,y:worldY,createdAt:reactionNow,expiresAt:reactionNow+Math.max(3000,${steps}*25)});
applyRealtimeReaction({id:'benchmark-reaction',emoji:REACTION_EMOJIS[0],style:'firework',x:worldX,y:worldY,createdAt:reactionNow,expiresAt:reactionNow+Math.max(3400,${steps}*25)});
const dispatch=(type,index,buttons)=>viewport.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId,pointerType:'mouse',isPrimary:true,button:type==='pointerdown'||type==='pointerup'?2:-1,buttons,clientX:startX-index*7,clientY:startY-index*3}));
dispatch('pointerdown',0,2);let index=0;
const tick=()=>{index++;dispatch('pointermove',index,2);if(index===10)queueWorldSignal({sessionId:'benchmark-cross-tab',commitId:'benchmark:'+Date.now(),worldEpoch:data.worldEpoch,stateIds:['B1']});if(index<${steps})requestAnimationFrame(tick);else{dispatch('pointerup',index,0);setTimeout(()=>Promise.resolve(syncQueue).finally(()=>{remotePlayers.delete(remoteId);realtimeReactions.delete('benchmark-reaction');resolve(index)}),80)}};
const tick=()=>{index++;dispatch('pointermove',index,2);if(index<${steps})requestAnimationFrame(tick);else{dispatch('pointerup',index,0);setTimeout(()=>{remotePlayers.delete(remoteId);realtimeReactions.delete('benchmark-reaction');resolve(index)},80)}};
requestAnimationFrame(tick);
})`);
}
@ -295,7 +285,9 @@ async function pinchZoomProbe(client){
})`);
}
async function pickupEdgePanProbe(client,pathRow){
const originalState=await client.evaluate("deepClone(metaState('B0'))"),start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
const originalState=await client.evaluate("deepClone(metaState('B0'))");
await client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect(),p=worldUnitAtClient(r.left+r.width/2,r.top+r.height/2),now=trustedNow();applyRealtimeReaction({id:'benchmark-edge-pan-effect',emoji:REACTION_EMOJIS[0],style:'comet',x:p[0],y:p[1],createdAt:now,expiresAt:now+12000});return true})()");
const start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
edge=await client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect();return{x:r.right-2,y:r.top+r.height/2}})()"),
before=await client.evaluate("({x:cam.x,y:cam.y,frames:BEND_PERF.snapshot().counters.dragFrames||0})");
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button:'left',buttons:1,clickCount:1});
@ -306,7 +298,7 @@ async function pickupEdgePanProbe(client,pathRow){
return{cameraMoved:Math.hypot(after.x-before.x,after.y-before.y)>5,dragFrames:after.frames-before.frames};
}finally{
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:edge.x,y:edge.y,button:'left',buttons:0,clickCount:1}).catch(()=>{});
await client.evaluate(`(()=>{data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
await client.evaluate(`(()=>{realtimeReactions.delete('benchmark-edge-pan-effect');data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
await client.evaluate("(async()=>{await persistNow({skipCloud:true});return true})()");
}
}
@ -314,6 +306,7 @@ async function pickupCadenceProbe(client,pathRow,steps=120,sampleDelay=8){
const start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
center=await pointForCell(client,'B0',pathRow.cells[0][0],pathRow.cells[0][1]),
originalState=await client.evaluate("deepClone(metaState('B0'))");
await client.evaluate(`(()=>{const p=worldUnitAtClient(${start.x},${start.y}),now=trustedNow();applyRealtimeReaction({id:'benchmark-pickup-effect',emoji:REACTION_EMOJIS[0],style:'firework',x:p[0],y:p[1],createdAt:now,expiresAt:now+${Math.max(12000,steps*sampleDelay*2)}});return true})()`);
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button:'left',buttons:1,clickCount:1});
try{
await waitFor(()=>client.evaluate("Boolean(rendered.get('B0')?.drawing)"),{timeout:5000,label:'continuous pickup activation'});
@ -327,7 +320,7 @@ async function pickupCadenceProbe(client,pathRow,steps=120,sampleDelay=8){
}finally{
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:center.x,y:center.y,button:'left',buttons:0,clickCount:1}).catch(()=>{});
await sleep(120);
await client.evaluate(`(()=>{data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
await client.evaluate(`(()=>{realtimeReactions.delete('benchmark-pickup-effect');data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
await client.evaluate("(async()=>{await persistNow({skipCloud:true});return true})()");
}
}
@ -383,19 +376,102 @@ async function measureDisplayCadence(client,frames=90){
async function measureCursorCadence(client,steps=180){
const setup=await client.evaluate(`(()=>{
const emoji=CURSOR_ITEMS.find(item=>item.cursorEmoji&&!item.flagAsset),rect=document.querySelector('#viewport').getBoundingClientRect();
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);BEND_PERF.reset();
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);BEND_PERF.reset();globalThis.__benchmarkCursorCommits=[];
globalThis.__benchmarkCommitCustomCursorFrame=commitCustomCursorFrame;commitCustomCursorFrame=(sample,timestamp)=>{globalThis.__benchmarkCursorCommits.push({timestamp,inputAt:sample.inputAt,revision:sample.revision});return globalThis.__benchmarkCommitCustomCursorFrame(sample,timestamp)};
return{left:rect.left+40,top:rect.top+40,width:Math.max(120,rect.width-80),height:Math.max(120,rect.height-80)};
})()`);
try{
const dispatches=[];
for(let index=0;index<steps;index++){
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:setup.left+(index*7)%setup.width,y:setup.top+(index*3)%setup.height,button:'none',buttons:0});
dispatches.push(client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:setup.left+(index*7)%setup.width,y:setup.top+(index*3)%setup.height,button:'none',buttons:0}));
await sleep(8);
}
await sleep(120);const snapshot=await client.evaluate('BEND_PERF.snapshot()'),gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
assert(gap.count>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify(gap)}`);
assert(age.count>=25&&age.p95<25,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
await Promise.all(dispatches);
await sleep(120);const measured=await client.evaluate(`(()=>{
const snapshot=BEND_PERF.snapshot(),commits=globalThis.__benchmarkCursorCommits||[],gaps=commits.slice(1).map((entry,index)=>entry.timestamp-commits[index].timestamp);
return{snapshot,diagnostic:{interval:DRAG_FRAME_INTERVAL,tolerance:INTERACTION_FRAME_TOLERANCE_MS,commitCount:commits.length,gaps:gaps.slice(0,20),lastDraw:customCursorLastDraw,inputRevision:customCursorInputRevision,committedRevision:customCursorCommittedRevision}};
})()`),snapshot=measured.snapshot,gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
assert(gap.count>=6&&gap.p50<=40,`DOM cursor cadence missed the capped 30 FPS acceptance: ${JSON.stringify({gap,diagnostic:measured.diagnostic})}`);
assert(age.count>=8&&age.p95<45,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
return snapshot;
}finally{await client.evaluate("localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
}finally{await client.evaluate("if(globalThis.__benchmarkCommitCustomCursorFrame)commitCustomCursorFrame=globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCursorCommits;localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
}
async function measureEffectsAndCosmetics(client,{cpuRate=1,visual=true,inventory=true,aurora=true,memory=true,singles=true}={}){
await client.send('Emulation.setCPUThrottlingRate',{rate:cpuRate});
return client.evaluate(`(async()=>{
const options=${JSON.stringify({cpuRate,visual,inventory,aurora,memory,singles,visualCases:effectVisualFixture.cases,visualWidth:effectVisualFixture.width,visualHeight:effectVisualFixture.height,perChannelTolerance:effectVisualFixture.perChannelTolerance,maximumChangedPixelRatio:effectVisualFixture.maximumChangedPixelRatio})},wait=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds)),
styles=['classic','giant','laser','orbit','firework','comet'],emoji=REACTION_EMOJIS[0],
runSet=async(runStyles,label,duration=900)=>{
for(const style of new Set(runStyles))BEND_PERF.warmEffectCache(emoji,style);await new Promise(resolve=>requestAnimationFrame(resolve));await wait(80);
BEND_PERF.reset();const rect=document.querySelector('#viewport').getBoundingClientRect(),center=worldUnitAtClient(rect.left+rect.width/2,rect.top+rect.height/2),now=trustedNow();
runStyles.forEach((style,index)=>{const effectDuration=reactionDurationForStyle(style),angle=index*Math.PI*2/Math.max(1,runStyles.length),radius=runStyles.length>1?1.35:0;applyRealtimeReaction({id:'effect-benchmark-'+label+'-'+index,emoji,style,x:center[0]+Math.cos(angle)*radius,y:center[1]+Math.sin(angle)*radius,createdAt:now-effectDuration*.22,expiresAt:now+effectDuration*.78})});
await wait(duration);const snapshot=BEND_PERF.snapshot();realtimeReactions.clear();reactionDirty=true;scheduleReactionRender(true);await wait(90);return snapshot;
},
loadPixels=dataUrl=>new Promise((resolve,reject)=>{const image=new Image();image.onload=()=>{const canvas=document.createElement('canvas');canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;const context=canvas.getContext('2d',{willReadFrequently:true});context.drawImage(image,0,0);resolve({width:canvas.width,height:canvas.height,data:context.getImageData(0,0,canvas.width,canvas.height).data})};image.onerror=reject;image.src=dataUrl}),
compareSamples=async(directUrl,cachedUrl)=>{const[a,b]=await Promise.all([loadPixels(directUrl),loadPixels(cachedUrl)]);let changed=0,active=0,alphaDelta=0,minX=a.width,minY=a.height,maxX=-1,maxY=-1;for(let offset=0;offset<a.data.length;offset+=4){const pixel=offset/4,x=pixel%a.width,y=Math.floor(pixel/a.width),visible=a.data[offset+3]>0||b.data[offset+3]>0;if(visible){active++;minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x);maxY=Math.max(maxY,y)}if(Math.abs(a.data[offset]-b.data[offset])>options.perChannelTolerance||Math.abs(a.data[offset+1]-b.data[offset+1])>options.perChannelTolerance||Math.abs(a.data[offset+2]-b.data[offset+2])>options.perChannelTolerance||Math.abs(a.data[offset+3]-b.data[offset+3])>options.perChannelTolerance)changed++;alphaDelta+=Math.abs(a.data[offset+3]-b.data[offset+3])}return{changed,total:a.width*a.height,ratio:changed/(a.width*a.height),active,alphaDelta,bounds:[minX,minY,maxX,maxY]}},
result={cpuRate:options.cpuRate,singles:{},visual:[],visualGate:{perChannelTolerance:options.perChannelTolerance,maximumChangedPixelRatio:options.maximumChangedPixelRatio,width:options.visualWidth,height:options.visualHeight},inventory:null,aurora:null,memory:null};
BEND_PERF.clearEffectCaches();
if(options.singles)for(const style of styles)result.singles[style]=await runSet([style],style,style==='classic'?620:1200);
result.overlap4=await runSet(['giant','laser','orbit','firework'],'overlap4',1100);
result.overlap8=await runSet(['giant','laser','orbit','firework','comet','laser','orbit','firework'],'overlap8',1100);
if(options.cpuRate===1){
const board=rendered.get('B0'),setup={};BEND_PERF.reset();for(let index=0;index<40;index++){const style=styles[index%styles.length],duration=reactionDurationForStyle(style),id='effect-publish-'+index,now=trustedNow();applyRealtimeReaction({id,emoji,style,x:0,y:0,createdAt:now,expiresAt:now+duration});realtimeReactions.delete(id)}cancelReactionRenderScheduler(true);setup.reaction=BEND_PERF.snapshot();
if(board){cleanupGemEffects();BEND_PERF.reset();for(let index=0;index<30;index++){playGemCollectionAnimation(board,100000);cleanupGemEffects()}setup.gem=BEND_PERF.snapshot();skipCompletionVisuals();BEND_PERF.reset();for(let index=0;index<30;index++){completionEffect(board,1000);finishCompletionVisual(board.id,false)}setup.completion=BEND_PERF.snapshot()}
result.setup=setup;
}
if(options.visual){
for(const{style,life}of options.visualCases){
const direct=BEND_PERF.renderReactionSample({style,emoji,life,width:options.visualWidth,height:options.visualHeight,glyphCache:false,pathCache:false}),cached=BEND_PERF.renderReactionSample({style,emoji,life,width:options.visualWidth,height:options.visualHeight,glyphCache:true,pathCache:true});
result.visual.push({style,life,...await compareSamples(direct.dataUrl,cached.dataUrl)});
}
}
if(options.inventory){
const previousCursor=data.cursorStyle,previousLineColor=data.lineColorStyle,previousInventoryEntries=inventoryEntries,syntheticEntries=STORE_ITEMS.map((item,index)=>({meta:null,st:null,store:null,purchase:{id:item.id,itemId:item.id,boughtAt:index+1,paidCost:0},personal:true}));inventoryEntries=itemId=>itemId?syntheticEntries.filter(entry=>entry.purchase.id===itemId):[...syntheticEntries];invalidateEconomyCaches();await wait(100);BEND_PERF.reset();const resourcesBefore=performance.getEntriesByType('resource').filter(entry=>entry.name.includes('/assets/flags/')).length;
openInventory();await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));const renderSnapshot=BEND_PERF.snapshot();inventoryPanel.scrollTop=Math.min(480,Math.max(0,inventoryPanel.scrollHeight-inventoryPanel.clientHeight));const scrollBefore=inventoryPanel.scrollTop,auroraItem=STORE_ITEMS.find(item=>item.aurora===true),focusTarget=auroraItem?inventoryItemViews.get(auroraItem.id)?.use:null,nodesBefore=inventoryList.querySelectorAll('*').length;focusTarget?.focus({preventScroll:true});BEND_PERF.reset();
for(let index=0;index<40;index++){data.lineColorStyle=index%2?previousLineColor:auroraItem.id;patchInventoryItems([auroraItem.id,previousLineColor])}await new Promise(resolve=>requestAnimationFrame(resolve));
const patchSnapshot=BEND_PERF.snapshot(),resourcesAfter=performance.getEntriesByType('resource').filter(entry=>entry.name.includes('/assets/flags/')).length;result.inventory={renderSnapshot,patchSnapshot,scrollBefore,scrollAfter:inventoryPanel.scrollTop,focusRetained:!focusTarget||document.activeElement===focusTarget,resourceDelta:resourcesAfter-resourcesBefore,mounted:inventoryList.querySelectorAll('[data-item-id]').length,nodesBefore,nodesAfter:inventoryList.querySelectorAll('*').length};
closeInventory(false);inventoryEntries=previousInventoryEntries;invalidateEconomyCaches();data.cursorStyle=previousCursor;data.lineColorStyle=previousLineColor;syncCursorAppearance(previousCursor);syncCosmeticAppearance();renderInventoryPanel();
}
if(options.aurora){
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'),fragment=document.createDocumentFragment();svg.setAttribute('aria-hidden','true');svg.style.cssText='position:absolute;width:1px;height:1px;overflow:visible;pointer-events:none';
for(let index=0;index<500;index++){const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('class','path line-effect-aurora');path.setAttribute('d',\`M0 \${index%25} L100 \${index%25}\`);fragment.append(path)}svg.append(fragment);world.append(svg);const previousCount=auroraVisiblePathCount;auroraVisiblePathCount+=500;BEND_PERF.reset();updateAuroraAnimationState();await wait(4300);const active=BEND_PERF.snapshot();auroraVisiblePathCount=0;updateAuroraAnimationState();BEND_PERF.reset();await wait(2300);const inactive=BEND_PERF.snapshot();
Object.defineProperty(document,'visibilityState',{value:'hidden',configurable:true});auroraVisiblePathCount=500;document.dispatchEvent(new Event('visibilitychange'));BEND_PERF.reset();await wait(2300);const hidden=BEND_PERF.snapshot();delete document.visibilityState;auroraVisiblePathCount=previousCount;svg.remove();document.dispatchEvent(new Event('visibilitychange'));updateAuroraAnimationState();result.aurora={active,inactive,hidden};
}
if(options.memory&&globalThis.gc&&performance.memory){
const board=rendered.get('B0');BEND_PERF.clearEffectCaches();cleanupGemEffects();skipCompletionVisuals();globalThis.gc();await wait(80);const before=performance.memory.usedJSHeapSize;
for(let index=0;index<100;index++){const style=styles[index%styles.length],duration=reactionDurationForStyle(style),id='effect-memory-'+index,now=trustedNow();applyRealtimeReaction({id,emoji,style,x:0,y:0,createdAt:now-duration*.5,expiresAt:now+duration*.5});reactionDirty=true;drawReactionLayer();realtimeReactions.delete(id);cancelReactionRenderScheduler(true);if(board){playGemCollectionAnimation(board,100000);cleanupGemEffects();completionEffect(board,1000);finishCompletionVisual(board.id,false)}}
reactionDirty=true;drawReactionLayer();cancelReactionRenderScheduler(true);cleanupGemEffects();skipCompletionVisuals();BEND_PERF.clearEffectCaches();globalThis.gc();await wait(120);globalThis.gc();result.memory={before,after:performance.memory.usedJSHeapSize,delta:performance.memory.usedJSHeapSize-before,active:realtimeReactions.size,gemBatches:activeGemBatches.size,completionVisuals:activeCompletionVisuals.size,effectNodes:document.querySelectorAll('.gem-particle,.completion-flash,.completion-burst').length,cacheEntries:(BEND_PERF.snapshot().gauges.reactionGlyphCacheEntries||0)+(BEND_PERF.snapshot().gauges.reactionStaticPathCacheEntries||0)};
}
return result;
})()`);
}
function validateEffectsAndCosmetics(result,{mobile=false}={}){
const cpuRate=result.cpuRate,normalSpeed=cpuRate===1,singleLimit=normalSpeed?6:40,fourLimit=normalSpeed?12:80,longTaskLimit=normalSpeed?50:500,minFrames=normalSpeed?8:5,minGaps=normalSpeed?7:4;
for(const[style,snapshot]of Object.entries(result.singles||{})){
const draw=timing(snapshot,`reactionStyle.${style}`),gap=timing(snapshot,'reactionFrameGap');
assert(draw.count>=minFrames&&(!normalSpeed||draw.p95<=singleLimit&&draw.p99<=8),`${mobile?'mobile ':''}${style}/${cpuRate}x reaction work missed acceptance: ${JSON.stringify({draw,singleLimit,minFrames})}`);
if(normalSpeed)assert(gap.count>=minGaps&&gap.p50<=45,`${style}/${cpuRate}x single-effect median cadence missed acceptance: ${JSON.stringify({gap,draw:timing(snapshot,'reactionFrame'),rates:snapshot.rates,counters:snapshot.counters})}`);
assert(timing(snapshot,'reactionFrame').max<longTaskLimit,`${style}/${cpuRate}x reaction callback exceeded its stress ceiling: ${JSON.stringify(timing(snapshot,'reactionFrame'))}`);
}
const four=timing(result.overlap4,'reactionFrame'),eight=timing(result.overlap8,'reactionFrame'),fourGap=timing(result.overlap4,'reactionFrameGap');
assert(four.count>=minFrames&&(!normalSpeed||four.p95<=fourLimit&&four.p99<=20)&&four.max<longTaskLimit,`${mobile?'mobile ':''}four-effect/${cpuRate}x reaction missed its work budget: ${JSON.stringify({four,fourGap,fourLimit,minFrames,rates:result.overlap4.rates,counters:result.overlap4.counters})}`);
assert(eight.count>=(normalSpeed?6:4)&&eight.max<longTaskLimit&&(result.overlap8.gauges.peakVisibleReactions||0)>=8,`Eight-effect overload did not render every valid reaction: ${JSON.stringify({eight,peakVisible:result.overlap8.gauges.peakVisibleReactions,visible:result.overlap8.gauges.visibleReactions})}`);
for(const snapshot of[result.overlap4,result.overlap8]){
assert((snapshot.rates.reactionDeadlineTimerCallbacksPerSecond||0)<=37.5,`Reaction deadline timer exceeded the 37.5 callbacks/s short-window envelope: ${snapshot.rates.reactionDeadlineTimerCallbacksPerSecond}`);
assert((snapshot.rates.reactionDrawRafCallbacksPerSecond||0)<=37.5,`Reaction draw RAF exceeded the 37.5 callbacks/s short-window envelope: ${snapshot.rates.reactionDrawRafCallbacksPerSecond}`);
}
if(result.visual?.length){const worst=result.visual.reduce((a,b)=>a.ratio>b.ratio?a:b),limit=result.visualGate?.maximumChangedPixelRatio??.005;assert(worst.ratio<=limit,`Cached effect pixels changed beyond tolerance: ${JSON.stringify({worst,gate:result.visualGate})}`)}
if(result.setup){assert(timing(result.setup.reaction,'reactionPublish').p95<=2,`Reaction publication exceeded 2 ms: ${JSON.stringify(timing(result.setup.reaction,'reactionPublish'))}`);assert(timing(result.setup.gem,'gemEffectSetup').p95<=3,`Gem setup exceeded 3 ms: ${JSON.stringify(timing(result.setup.gem,'gemEffectSetup'))}`);assert(timing(result.setup.completion,'completionEffectSetup').p95<=3,`Completion setup exceeded 3 ms: ${JSON.stringify(timing(result.setup.completion,'completionEffectSetup'))}`)}
if(result.inventory){
const render=timing(result.inventory.renderSnapshot,'inventoryRender'),initialPatch=timing(result.inventory.renderSnapshot,'inventoryPatch'),initial=render.count?render:initialPatch,patch=timing(result.inventory.patchSnapshot,'inventoryPatch');
assert(initial.count&&initial.p95<=100,`Full cosmetic inventory render exceeded 100 ms: ${JSON.stringify({render,initialPatch})}`);assert(patch.count>=20&&patch.p95<=8,`Cosmetic equip patch exceeded 8 ms: ${JSON.stringify({patch})}`);
assert(result.inventory.scrollAfter===result.inventory.scrollBefore,`Cosmetic equip moved inventory scroll from ${result.inventory.scrollBefore} to ${result.inventory.scrollAfter}`);assert(result.inventory.focusRetained,'Cosmetic equip moved keyboard focus');assert(result.inventory.nodesAfter===result.inventory.nodesBefore,'Cosmetic equip rebuilt inventory nodes');
assert((result.inventory.renderSnapshot.counters.longTasks||0)===0&&(result.inventory.patchSnapshot.counters.longTasks||0)===0,`Full cosmetic inventory produced a long task: ${JSON.stringify({render,patch,mounted:result.inventory.mounted})}`);
}
if(result.aurora){const active=timing(result.aurora.active,'auroraTick');assert(active.p95<=.25,`Aurora tick exceeded 0.25 ms: ${JSON.stringify(active)}`);const writes=result.aurora.active.counters.auroraColorWrites||0;assert(writes>=2&&writes<=3,`Aurora did not select one curated color every two seconds: ${writes}`);for(const[name,snapshot]of Object.entries({inactive:result.aurora.inactive,hidden:result.aurora.hidden}))assert(!(snapshot.counters.auroraColorWrites||0)&&!timing(snapshot,'auroraTick').count,`Aurora performed work while ${name}: ${JSON.stringify(snapshot)}`)}
if(result.memory)assert(result.memory.active===0&&result.memory.gemBatches===0&&result.memory.completionVisuals===0&&result.memory.effectNodes===0&&result.memory.cacheEntries===0&&result.memory.delta<=2*1024*1024,`Effect cleanup retained too much state: ${JSON.stringify(result.memory)}`);
}
async function zoom(client,deltaY,repetitions){
@ -407,7 +483,7 @@ async function zoom(client,deltaY,repetitions){
await sleep(300);
}
function timing(snapshot,name){return snapshot.timings?.[name]||{count:0,p50:0,p95:0,max:0}}
function timing(snapshot,name){return snapshot.timings?.[name]||{count:0,p50:0,p95:0,p99:0,max:0}}
async function measureGameplaySimplificationBudgets(client){
const result=await client.evaluate(`(async()=>{
const percentile=(rows,p)=>{const ordered=[...rows].sort((a,b)=>a-b);return ordered[Math.min(ordered.length-1,Math.floor(ordered.length*p))]||0};
@ -425,8 +501,6 @@ async function measureGameplaySimplificationBudgets(client){
};
const originalScale=cam.scale;cam.scale=OVERVIEW_ZOOM_THRESHOLD-.001;const overviewBelow=inWorldOverview();
cam.scale=OVERVIEW_ZOOM_THRESHOLD+.001;const overviewAbove=inWorldOverview();cam.scale=originalScale;inWorldOverview();
const summaryProbe=document.createElementNS('http://www.w3.org/2000/svg','path');summaryProbe.classList.add('static-summary-fill');
document.body.append(summaryProbe);const summaryFill=getComputedStyle(summaryProbe).fill;summaryProbe.remove();
const active=rendered.get(activeBoard);let northHudGap=null;
if(active){positionBoardLabel(active);if(active.label.dataset.side==='N'){const placement=hudPlacementCandidates(active.meta)[0],edgeY=PAD+placement.dy*UNIT;northHudGap=edgeY-parseFloat(active.label.style.top)}}
let heartbeats=0;const heartbeat=setInterval(()=>heartbeats++,0),workerStarted=performance.now();
@ -434,7 +508,7 @@ async function measureGameplaySimplificationBudgets(client){
const workerElapsed=performance.now()-workerStarted;clearInterval(heartbeat);
return{
snap,pointerSamples,minimap,noise,workerElapsed,heartbeats,workerStatus:workerResult.status,
controls,overviewBelow,overviewAbove,summaryFill,northHudGap,
controls,overviewBelow,overviewAbove,northHudGap,
cellHitCount:document.querySelectorAll('.cell-hit').length,
cellShapeCount:document.querySelectorAll('.board-card .cell-shape').length,
renderedBoardCount:rendered.size,
@ -450,7 +524,6 @@ async function measureGameplaySimplificationBudgets(client){
assert(result.noise.p95<10&&result.noise.max<20,`Noise update p95/max ${result.noise.p95.toFixed(3)}/${result.noise.max.toFixed(3)} ms exceeded the budget`);
assert(result.controls.origin&&result.controls.random&&!result.controls.unsolved&&!result.controls.current,'Minimap teleport controls do not match origin + random');
assert(result.overviewBelow&&!result.overviewAbove,'World overview retained zoom hysteresis');
assert(result.summaryFill==='rgba(0, 0, 0, 0)'||result.summaryFill==='transparent',`Nearby board summary still has a filled square (${result.summaryFill})`);
if(result.northHudGap!=null)assert(Math.abs(result.northHudGap-22)<.01,`Top puzzle HUD gap is ${result.northHudGap}, expected 22 world pixels`);
assert(result.cellHitCount===0&&result.cellShapeCount>0,'Detailed boards still allocate one hit node per cell or lack compound cell paths');
assert(result.visibleDetailedCount===result.visiblePuzzleCount,'A visible valid board was hidden from detailed rendering');
@ -467,14 +540,14 @@ function validateMeasurement(result){
dragAge=timing(cadenceSnapshot,'pickupVisualInputAge'),cameraAge=timing(snapshot,'cameraInputAge'),
minimap=timing(snapshot,'drawMinimap'),ensure=timing(snapshot,'ensureBoards'),save=timing(snapshot,'persistDirtyToDb'),
overview=timing(snapshot,'drawWorldOverview'),mirrorChunk=timing(snapshot,'mirrorChunkWrite'),
dragLimit=cpuRate===1?8:16,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
dragLimit=cpuRate===1?8:16,functionalDragLimit=cpuRate===1?18:24,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
assert(drag.count+probeDrag.count>=5,`${profile}/${cpuRate}x captured only ${drag.count} functional and ${probeDrag.count} continuous-input drag frames: ${JSON.stringify(result.pickupProbe)}`);
assert(camera.count>=5,`${profile}/${cpuRate}x did not capture frame-coalesced camera work`);
assert(minimap.count>=2,`${profile}/${cpuRate}x did not capture minimap work`);
assert(ensure.count>=2,`${profile}/${cpuRate}x did not capture LOD work`);
assert(save.count>=1,`${profile}/${cpuRate}x did not capture an autosave`);
assert(overview.count>=1,`${profile}/${cpuRate}x did not capture overview rendering`);
assert(cameraGap.count>=5&&(cpuRate!==1||dragGap.count>=4),`${profile}/${cpuRate}x did not capture enough real interaction cadence samples (pickup ${dragGap.count}, camera ${cameraGap.count})`);
assert(cameraGap.count>=4&&(cpuRate!==1||dragGap.count>=4),`${profile}/${cpuRate}x did not capture enough real interaction cadence samples (pickup ${dragGap.count}, camera ${cameraGap.count})`);
if(cpuRate===1){
const approved=result.claimApproved,denied=result.claimDenied;
assert(approved?.previewWithinFrame&&approved.tracksLatest&&approved.modelUntouched&&approved.approved&&approved.noJump&&approved.fullBoardRenders===0,`${profile} claim approval preview/commit failed: ${JSON.stringify(approved)}`);
@ -484,27 +557,27 @@ function validateMeasurement(result){
assert(result.pinch?.changed,`${profile}/${cpuRate}x pinch zoom did not change camera scale: ${JSON.stringify(result.pinch)}`);
assert(result.overviewPathsObserved===0,`${profile}/${cpuRate}x far overview rendered route lines`);
assert((snapshot.counters.overviewCacheBuilds||0)>result.overviewBuildBaseline,`${profile}/${cpuRate}x long overview pan did not rebuild the exhausted cache after settlement`);
assert((snapshot.counters.overviewBuildsDuringInteraction||0)>=1&&(snapshot.counters.overviewBuildsDuringInteraction||0)<=60,`${profile}/${cpuRate}x long overview pan did not use a bounded in-gesture cache refresh`);
if(cpuRate===1){
const cadenceHot=Object.entries(cadenceSnapshot.timings||{}).filter(([,value])=>value.max>1).sort((a,b)=>b[1].max-a[1].max).slice(0,12);
assert(dragGap.p50<=18&&dragGap.p95<=22,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 60 Hz cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
assert(cameraGap.p50<=20,`${profile} camera median gap ${cameraGap.p50.toFixed(2)} ms exceeded 20 ms`);
assert(dragAge.p95<30,`${profile} pickup input age ${dragAge.p95.toFixed(2)} ms exceeded 30 ms`);
assert(cameraAge.p95<25,`${profile} camera input age ${cameraAge.p95.toFixed(2)} ms exceeded 25 ms`);
assert(dragGap.p50<=40&&dragGap.p95<=50,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 30 FPS cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
assert(cameraGap.p50<=40,`${profile} camera median gap ${cameraGap.p50.toFixed(2)} ms exceeded the capped 30 FPS cadence budget`);
assert(dragAge.p95<45,`${profile} pickup input age ${dragAge.p95.toFixed(2)} ms exceeded 45 ms`);
assert(cameraAge.p95<45,`${profile} camera input age ${cameraAge.p95.toFixed(2)} ms exceeded 45 ms`);
}
assert(Math.max(drag.p95,probeDrag.p95)<=dragLimit,`${profile}/${cpuRate}x drag p95 ${Math.max(drag.p95,probeDrag.p95).toFixed(2)} ms exceeded acceptance`);
assert(drag.p95<=functionalDragLimit&&probeDrag.p95<=dragLimit,`${profile}/${cpuRate}x drag work exceeded acceptance (functional ${drag.p95.toFixed(2)}/${functionalDragLimit} ms, cadence ${probeDrag.p95.toFixed(2)}/${dragLimit} ms, model ${timing(cadenceSnapshot,'pickupModelWork').p95.toFixed(2)}, visual ${timing(cadenceSnapshot,'pickupVisualWork').p95.toFixed(2)}, render ${timing(cadenceSnapshot,'renderDragFrame').p95.toFixed(2)})`);
assert(camera.p95<=dragLimit,`${profile}/${cpuRate}x camera p95 ${camera.p95.toFixed(2)} ms exceeded acceptance`);
assert(minimap.p95<=minimapLimit,`${profile}/${cpuRate}x minimap p95 ${minimap.p95.toFixed(2)} ms exceeded acceptance`);
assert(ensure.p95<=lodLimit,`${profile}/${cpuRate}x LOD p95 ${ensure.p95.toFixed(2)} ms exceeded acceptance`);
if(mirrorChunk.count)assert(mirrorChunk.max<50,`${profile}/${cpuRate}x mirror chunk write ${mirrorChunk.max.toFixed(2)} ms became a long task`);
if(cpuRate===1){
const modelWork=timing(snapshot,'pickupModelWork'),visualWork=timing(snapshot,'pickupVisualWork'),dragRender=timing(snapshot,'renderDragFrame');
assert(result.pickupProbeLongTasks===0&&result.pickupLongTasks===0&&result.panLongTasks===0,`${profile} recorded a 50 ms long task during continuous pickup (${result.pickupProbeLongTasks}), real pickup (${result.pickupLongTasks}), or the ten-second pan (${result.panLongTasks}); last ${snapshot.gauges.lastInteractionLongTaskMs||0} ms; phases preview ${timing(snapshot,'pickupPointerDownPreview').max.toFixed(1)}, commit ${timing(snapshot,'pickupPointerDownCommit').max.toFixed(1)}, finish ${timing(snapshot,'pickupPointerFinish').max.toFixed(1)}, drag ${drag.max.toFixed(1)}, model ${modelWork.max.toFixed(1)}, visual ${visualWork.max.toFixed(1)}, render ${dragRender.max.toFixed(1)}; ${snapshot.gauges.lastInteractionLoafScripts||'no LoAF attribution'}`);
assert(result.pickupProbeLongTasks===0&&result.pickupLongTasks===0&&result.panLongTasks===0,`${profile} recorded a 50 ms long task during continuous pickup (${result.pickupProbeLongTasks}), real pickup (${result.pickupLongTasks}), or the ten-second pan (${result.panLongTasks}); last ${snapshot.gauges.lastInteractionLongTaskMs||0} ms; phases preview ${timing(snapshot,'pickupPointerDownPreview').max.toFixed(1)}, commit ${timing(snapshot,'pickupPointerDownCommit').max.toFixed(1)}, finish ${timing(snapshot,'pickupPointerFinish').max.toFixed(1)}, drag ${drag.max.toFixed(1)}, model ${modelWork.max.toFixed(1)} [prepare ${timing(snapshot,'pickupModelPrepare').max.toFixed(1)}, topology ${timing(snapshot,'pickupModelTopology').max.toFixed(1)}, traversal ${timing(snapshot,'pickupModelTraversal').max.toFixed(1)}, cell ${timing(snapshot,'pickupModelCell').max.toFixed(1)}], visual ${visualWork.max.toFixed(1)}, render ${dragRender.max.toFixed(1)}; ${snapshot.gauges.lastInteractionLoafScripts||'no LoAF attribution'}`);
}
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','overviewBuildsDuringInteraction','persistenceDuringInteraction','worldRefreshesDuringInteraction'])
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','persistenceDuringInteraction','worldRefreshesDuringInteraction'])
assert((snapshot.counters[name]||0)===0&&(cadenceSnapshot.counters[name]||0)===0,`${profile}/${cpuRate}x ran ${name} during an active gesture`);
assert((snapshot.counters.worldRefreshesDeferredDuringInteraction||0)>=1,`${profile}/${cpuRate}x did not defer the injected cross-tab refresh until gesture settlement`);
assert(snapshot.gauges.renderedBoards>=snapshot.gauges.visibleUnsolvedBoards,`${profile}/${cpuRate}x omitted a visible unsolved board from detailed rendering`);
assert((cadenceSnapshot.rates.dragFramesPerSecond||0)<=65,`${profile}/${cpuRate}x pickup presentation exceeded the 60 FPS ceiling (${(cadenceSnapshot.rates.dragFramesPerSecond||0).toFixed(1)} FPS)`);
assert((cadenceSnapshot.rates.dragFramesPerSecond||0)<=35,`${profile}/${cpuRate}x pickup presentation exceeded the 30 FPS ceiling (${(cadenceSnapshot.rates.dragFramesPerSecond||0).toFixed(1)} FPS)`);
assert(snapshot.gauges.domNodes<18000,`${profile}/${cpuRate}x DOM size is not viewport-bounded`);
}
@ -533,7 +606,7 @@ async function measureScenario(client,starter,profile,cpuRate){
assert(preSolveState.pathCount===0&&!preSolveState.solved&&!preSolveState.drawing&&!preSolveState.pending,`${profile.name}/${cpuRate}x pickup probes did not restore a pristine origin board: ${JSON.stringify(preSolveState)}`);
await solveOrigin(client,solution,cpuRate);
try{await waitFor(()=>client.evaluate("metaState('B0').solved===true"),{timeout:5000*Math.max(1,cpuRate),label:`${profile.name}/${cpuRate}x solved origin`})}
catch(error){const diagnostic=await client.evaluate("(()=>{const state=metaState('B0'),board=rendered.get('B0');return{solved:state.solved,paths:state.paths.map(path=>({startGate:path.startGate,endGate:path.endGate,openGate:path.openGate,cells:path.cells})),drawing:board?.drawing?{pathIndex:board.drawing.pathIndex,catchupPending:board.drawing.catchupPending}:null,queued:board?.pointerMoveSamples?.length||0,logicalTrace:board?.logicalPointerCellTrace||[],flushTrace:board?.lastFlushPointerCells||[],staticBoard:staticRendered.has('B0')}})()");throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`)}
catch(error){const diagnostic=await client.evaluate("(()=>{const state=metaState('B0'),board=rendered.get('B0');return{solved:state.solved,paths:state.paths.map(path=>({startGate:path.startGate,endGate:path.endGate,openGate:path.openGate,cells:path.cells})),drawing:board?.drawing?{pathIndex:board.drawing.pathIndex,catchupPending:board.drawing.catchupPending}:null,drag:board?.dragScheduler?.inspect?.()||null,logicalTrace:board?.logicalPointerCellTrace||[],flushTrace:board?.lastFlushPointerCells||[]}})()");throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`)}
await sleep(850);
const pickupLongTasks=await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0');
await client.evaluate('finishCompletionVisual("B0",true);centerMeta(data.metas.B0);true');await sleep(350);
@ -541,6 +614,7 @@ async function measureScenario(client,starter,profile,cpuRate){
leftProbe=await client.evaluate(`(()=>{const target=document.elementFromPoint(${leftStart.x},${leftStart.y});return{tag:target?.tagName||null,classes:target?.getAttribute?.('class')||null,board:target?.closest?.('.board-card')?.dataset?.id||null,allowed:leftFieldPanAllowed({button:0,target})}})()`);
const beforeLeftPan=await client.evaluate('({x:cam.x,y:cam.y,solved:metaState("B0").solved})');
assert(beforeLeftPan.solved&&leftProbe.allowed,`${profile.name}/${cpuRate}x solved-board left drag is not eligible for panning: ${JSON.stringify({leftStart,leftProbe,beforeLeftPan})}`);
await client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect(),p=worldUnitAtClient(r.left+r.width/2,r.top+r.height/2),now=trustedNow();applyRealtimeReaction({id:'benchmark-overview-effect',emoji:REACTION_EMOJIS[0],style:'orbit',x:p[0],y:p[1],createdAt:now,expiresAt:now+20000});return true})()");
await zoom(client,180,15);
const overviewPathsObserved=await client.evaluate('drawWorldOverview();BEND_PERF.snapshot().gauges.overviewPaths||0');
const overviewBuildBaseline=await client.evaluate('BEND_PERF.snapshot().counters.overviewCacheBuilds||0');
@ -550,7 +624,7 @@ async function measureScenario(client,starter,profile,cpuRate){
await waitFor(()=>client.evaluate(`(()=>{if(!inWorldOverview()||!overviewCache)return false;const[centerX,centerY]=cameraCenterInChunks();return!overviewDirty&&Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit<=overviewCache.overscan*.82&&Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit<=overviewCache.overscan*.82})()`),{timeout:10000,label:'settled overview cache rebuild'});
const pinch=await pinchZoomProbe(client);
await sleep(80);
const panLongTasks=(await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0'))-panLongTaskBaseline;
const panLongTasks=(await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0'))-panLongTaskBaseline;await client.evaluate("realtimeReactions.delete('benchmark-overview-effect');true");
await zoom(client,-180,15);
await sleep(650);
try{await waitFor(()=>client.evaluate("!hasPendingPersistence()"),{timeout:20000,label:'durable persistence drain'})}
@ -560,9 +634,26 @@ async function measureScenario(client,starter,profile,cpuRate){
}
await sleep(500);
const snapshot=await client.evaluate('BEND_PERF.snapshot()');
const result={profile:profile.name,boards:profile.boards,cpuRate,snapshot,pickupCadence,pickupProbe,overviewPathsObserved,overviewBuildBaseline,pickupProbeLongTasks,pickupLongTasks,panLongTasks,claimApproved,claimDenied,edgePan,pinch};
validateMeasurement(result);
return result;
return{profile:profile.name,boards:profile.boards,cpuRate,snapshot,pickupCadence,pickupProbe,overviewPathsObserved,overviewBuildBaseline,pickupProbeLongTasks,pickupLongTasks,panLongTasks,claimApproved,claimDenied,edgePan,pinch};
}
function profileSummaryRow(result){
return{
profile:result.profile,boards:result.boards,cpuRate:result.cpuRate,
dragP95:timing(result.snapshot,'processBoardDragFrame').p95,
cameraP95:timing(result.snapshot,'commitCameraInteraction').p95,
dragGapP95:timing(result.pickupCadence||result.snapshot,'pickupVisualFrameGap').p95,
dragInputAgeP95:timing(result.pickupCadence||result.snapshot,'pickupVisualInputAge').p95,
cameraGapP50:timing(result.snapshot,'cameraFrameGap').p50,
cameraInputAgeP95:timing(result.snapshot,'cameraInputAge').p95,
minimapP95:timing(result.snapshot,'drawMinimap').p95,
lodP95:timing(result.snapshot,'ensureBoards').p95,
saveP95:timing(result.snapshot,'persistDirtyToDb').p95,
overviewP95:timing(result.snapshot,'drawWorldOverview').p95,
renderedBoards:result.snapshot.gauges.renderedBoards,
staticBoards:result.snapshot.gauges.staticBoards,
domNodes:result.snapshot.gauges.domNodes,
longTasks:result.snapshot.counters.longTasks||0
};
}
async function main(runProfiles=profiles,cpuRates=[1,4]){
@ -573,6 +664,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
`--remote-debugging-port=${debuggingPort}`,'--remote-allow-origins=*',`--user-data-dir=${profilePath}`,
`--host-resolver-rules=MAP ${benchmarkHost} 127.0.0.1`,'--window-size=1440,1000','about:blank'
],{stdio:'ignore',windowsHide:true});
const report={capturedAt:new Date().toISOString(),browserPath:edgePath,benchmarkHost,status:'running',effects:{},profiles:[]};
let client=null;
try{
const target=await endpoint();client=new CdpClient(target.webSocketDebuggerUrl);await client.connect();
@ -581,49 +673,45 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
if(startupOnly){
await sleep(12000);
const state=await client.evaluate("({ready:document.body?.dataset?.ready||null,version:document.querySelector('.brand small')?.textContent||null,boards:typeof data!=='undefined'?Object.keys(data.metas).length:null,origin:typeof data!=='undefined'&&Boolean(data.metas?.B0?.puzzle),worldGeneration:typeof data!=='undefined'?data.worldGeneration:null,turnFont:getComputedStyle(document.querySelector('.board-svg text')||document.body).fontFamily,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,300)||''})");
assert(state.ready==='true'&&state.version==='v47.77'&&state.boards===1&&state.origin&&state.worldGeneration==='v47-field-reset-20260728-interaction-fix',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
assert(state.ready==='true'&&state.version==='v48.0'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
assert(/DotGothic16|Press Start 2P|MS Gothic|monospace/i.test(state.turnFont),'Dot-styled game font is not active in the browser');
console.log(`Real-browser startup passed: ${JSON.stringify(state)}`);return;
}
await ready(client);await sleep(1000);
const displayCadence=await measureDisplayCadence(client);
const displayCadence=await measureDisplayCadence(client);report.displayCadence=displayCadence;
assert(displayCadence.count>=60&&displayCadence.p50<=20,`Headless display baseline is not 60 Hz: ${JSON.stringify(displayCadence)}`);
const gameplayBudgets=await measureGameplaySimplificationBudgets(client);
const cursorModes=await measureCursorModes(client);
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='native'&&cursorModes.flagMode==='native'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&cursorModes.visibleDuringDrag&&cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
const cursorCadence=await measureCursorCadence(client);
const gameplayBudgets=await measureGameplaySimplificationBudgets(client);report.gameplayBudgets=gameplayBudgets;
const cursorModes=await measureCursorModes(client);report.cursorModes=cursorModes;
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='dom'&&cursorModes.flagMode==='dom'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&!cursorModes.visibleDuringDrag&&!cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
const cursorCadence=await measureCursorCadence(client);report.cursorCadence=cursorCadence;
console.log(`Display cadence | median ${displayCadence.p50.toFixed(2)} ms | p95 ${displayCadence.p95.toFixed(2)} ms`);
console.log(`Gameplay budgets | snap ${gameplayBudgets.snap.p95.toFixed(3)} ms | pointer samples ${gameplayBudgets.pointerSamples.p95.toFixed(3)} ms | minimap ${gameplayBudgets.minimap.p95.toFixed(3)} ms | noise max ${gameplayBudgets.noise.max.toFixed(3)} ms | worker ${gameplayBudgets.workerElapsed.toFixed(1)} ms`);
console.log(`Cursor cadence | median gap ${timing(cursorCadence,'cursorFrameGap').p50.toFixed(2)} ms | input p95 ${timing(cursorCadence,'cursorInputAge').p95.toFixed(2)} ms`);
const effects1x=await measureEffectsAndCosmetics(client,{cpuRate:1,visual:true,inventory:true,aurora:true,memory:true,singles:true});report.effects.desktop1x=effects1x;validateEffectsAndCosmetics(effects1x);
const effects4x=await measureEffectsAndCosmetics(client,{cpuRate:4,visual:false,inventory:false,aurora:false,memory:false,singles:true});report.effects.desktop4x=effects4x;validateEffectsAndCosmetics(effects4x);
await client.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true,screenWidth:390,screenHeight:844});await sleep(220);
const effectsMobile=await measureEffectsAndCosmetics(client,{cpuRate:1,visual:false,inventory:false,aurora:false,memory:false,singles:false});report.effects.mobile=effectsMobile;validateEffectsAndCosmetics(effectsMobile,{mobile:true});
await client.send('Emulation.clearDeviceMetricsOverride');await client.send('Emulation.setCPUThrottlingRate',{rate:1});await sleep(220);
console.log(`Effects | single firework ${timing(effects1x.singles.firework,'reactionStyle.firework').p95.toFixed(2)} ms | overlap4 ${timing(effects1x.overlap4,'reactionFrame').p95.toFixed(2)} ms | overlap8 ${timing(effects1x.overlap8,'reactionFrame').p95.toFixed(2)} ms | visual max ${(Math.max(...effects1x.visual.map(row=>row.ratio))*100).toFixed(3)}%`);
console.log(`EFFECT_BENCHMARK_JSON=${JSON.stringify({desktop1x:{single:Object.fromEntries(Object.entries(effects1x.singles).map(([style,snapshot])=>[style,timing(snapshot,`reactionStyle.${style}`)])),overlap4:timing(effects1x.overlap4,'reactionFrame'),overlap8:timing(effects1x.overlap8,'reactionFrame'),inventory:effects1x.inventory,memory:effects1x.memory,aurora:timing(effects1x.aurora.active,'auroraTick'),setup:effects1x.setup},desktop4x:{overlap4:timing(effects4x.overlap4,'reactionFrame'),overlap8:timing(effects4x.overlap8,'reactionFrame')},mobile:{overlap4:timing(effectsMobile.overlap4,'reactionFrame'),overlap8:timing(effectsMobile.overlap8,'reactionFrame')}})}`);
const starter=await readStarterRows(client),results=[];
for(const profile of runProfiles)for(const cpuRate of cpuRates){
const result=await measureScenario(client,starter,profile,cpuRate);results.push(result);
const result=await measureScenario(client,starter,profile,cpuRate);results.push(result);report.profiles=results.map(profileSummaryRow);validateMeasurement(result);
const drag=timing(result.snapshot,'processBoardDragFrame'),camera=timing(result.snapshot,'commitCameraInteraction'),minimap=timing(result.snapshot,'drawMinimap'),
ensure=timing(result.snapshot,'ensureBoards'),save=timing(result.snapshot,'persistDirtyToDb');
console.log(`${profile.name.padEnd(6)} ${cpuRate}x CPU | drag p95 ${drag.p95.toFixed(2)} ms | camera ${camera.p95.toFixed(2)} ms | minimap ${minimap.p95.toFixed(2)} ms | LOD ${ensure.p95.toFixed(2)} ms | save ${save.p95.toFixed(2)} ms | DOM ${result.snapshot.gauges.domNodes}`);
}
console.log(`BROWSER_BENCHMARK_JSON=${JSON.stringify(results.map(result=>({
profile:result.profile,boards:result.boards,cpuRate:result.cpuRate,
dragP95:timing(result.snapshot,'processBoardDragFrame').p95,
cameraP95:timing(result.snapshot,'commitCameraInteraction').p95,
dragGapP95:timing(result.pickupCadence||result.snapshot,'pickupVisualFrameGap').p95,
dragInputAgeP95:timing(result.pickupCadence||result.snapshot,'pickupVisualInputAge').p95,
cameraGapP50:timing(result.snapshot,'cameraFrameGap').p50,
cameraInputAgeP95:timing(result.snapshot,'cameraInputAge').p95,
minimapP95:timing(result.snapshot,'drawMinimap').p95,
lodP95:timing(result.snapshot,'ensureBoards').p95,
saveP95:timing(result.snapshot,'persistDirtyToDb').p95,
overviewP95:timing(result.snapshot,'drawWorldOverview').p95,
renderedBoards:result.snapshot.gauges.renderedBoards,
staticBoards:result.snapshot.gauges.staticBoards,
domNodes:result.snapshot.gauges.domNodes,
longTasks:result.snapshot.counters.longTasks||0
})))}`);
const profileSummary=results.map(profileSummaryRow);report.profiles=profileSummary;console.log(`BROWSER_BENCHMARK_JSON=${JSON.stringify(profileSummary)}`);
report.status='passed';writeBenchmarkReport(report);console.log(`Browser benchmark report: ${benchmarkOutputPath}`);
console.log('Real-browser performance benchmark passed');
}catch(error){
report.status='failed';report.failure={name:error?.name||'Error',message:error?.message||String(error),stack:error?.stack||''};
try{writeBenchmarkReport(report);console.error(`Browser benchmark failure report: ${benchmarkOutputPath}`)}catch(reportError){console.error(`Browser benchmark report write failed: ${reportError.message}`)}
throw error;
}finally{
client?.close();
stopBrowserTree(edge);
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));
if(server._linkfieldClose)await server._linkfieldClose();else{server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))}
for(let attempt=0;attempt<8;attempt++){
try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}
catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(150)}

View file

@ -17,10 +17,10 @@ assert(!app.includes('invalidateFieldOverlay')&&!app.includes('invalidateStoreEf
assert.equal(catalog.length>0,true);assert.equal(catalog.filter(item=>item.id==='score-lens').length,1);
// Clicking a board must select/input only; camera navigation is explicit elsewhere.
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false'));
assert(!app.includes('shouldTeleportToUnsolvedBoard'));
assert(!functionSource('bindBoard').includes('centerMeta('));
assert(!app.includes('function promoteStaticBoard('));
assert(functionSource('makeStaticBoard').includes('card.tabIndex=-1')&&!functionSource('makeStaticBoard').includes("setAttribute('role','button')"));
assert(!app.includes('makeStaticBoard')&&!app.includes('board-static'));
// Every hydrated puzzle in the visible field remains fully detailed.
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)'));
@ -37,13 +37,13 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardBytes>8*1024*1024'));
assert(functionSource('drawPresenceLayer').includes('dpr=1')&&functionSource('drawReactionLayer').includes('dpr=1'));
assert(functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'));
assert(!functionSource('applyCamera').includes('FRAME_INTERVAL')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'));
assert(functionSource('applyCamera').includes('GLOBAL_FRAME_INTERVAL')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'));
// Lightweight diagnostics and larger/longer completion reward visuals.
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}'));
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px'));
assert(functionSource('completionEffect').includes('1800'));
assert(functionSource('playGemCollectionAnimation').includes('duration=reduced?520:1450'));
assert(functionSource('playGemCollectionAnimation').includes('_gemDuration=reduced?520:1450'));
assert(!functionSource('bindBoard').includes('skipCompletionVisuals'));
// Purchase normalization must remain functional after deleting field purchases.

View file

@ -16,18 +16,18 @@ assert(!initialSource.includes('loadRecoveryCoverage('),
'Startup still performs the recovery coverage read in a second transaction');
assert(replaceSource.includes('await preserveRecoveryDurably('),
'World replacement does not await a verified recovery backup');
assert(functionSource('retryRecovery').includes('readRecoveryEnvelope')&&functionSource('retryRecovery').includes('stageRecoverySnapshotV2')&&functionSource('retryRecovery').includes("kind:'recovery'"),
'Recovery backup is not restored through the validated staged-world path');
assert(functionSource('runStatusRetry').includes('statusRetryAction')&&!app.includes('復元用バックアップがありません。'),
'The shared-server retry button still attempts obsolete local-backup recovery');
assert(clearSource.includes("activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'})"),
'Fresh-world reset does not use atomic epoch activation');
assert(!app.includes('worldMutationLockDepth')&&functionSource('withWorldMutationLock').includes("mode:'exclusive'"),
'World mutation locking still bypasses unrelated asynchronous callers');
assert(functionSource('persistNow').includes('if(options.lockHeld===true)return run()')&&functionSource('expandMetaNow').includes('lockHeld:true'),
'Nested expansion persistence can deadlock behind a queued lock waiter');
assert(app.includes('if(!worldInitReady){deferredWorldSignals.push(signal)'),
'Cross-tab messages are not buffered until initialization is complete');
assert(functionSource('initCloudSync').includes('if(cloudApiEnabled&&!cloudOutboxReady)'),
'Cloud synchronization is not fail-closed when IndexedDB health is uncertain');
assert(!app.includes('worldInitReady')&&!app.includes('deferredWorldSignals')&&!app.includes('BroadcastChannel'),
'Retired cross-tab board synchronization remains');
assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWorld===true")&&functionSource('initCloudSync').includes('resetClientToSingleSharedWorld()'),
'Startup does not require and adopt the single server-authoritative world');
const request=result=>({result});
function memoryStore(initial=[],keyOf=row=>row.id){
@ -101,6 +101,7 @@ function persistenceContext({localMeta=null,localState=null,existingMeta=null,ex
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
clearRecoveryJournalIfCovered:()=>{},broadcastWorldSignal:()=>{},scheduleCloudPush:()=>{},
refreshWorldView:()=>{context.refreshes++},refreshes:0,
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
perfStart:()=>0,perfEnd:()=>{},perfGauge:()=>{}
};
vm.createContext(context);
@ -153,8 +154,10 @@ async function verifyPersistenceConflicts(){
}
function verifyGlobalMerge(){
const {normalizeSpecialMechanics}=require('../shared-contracts');
const context={
deepClone:value=>structuredClone(value),structuredClone,
normalizeSpecialMechanics,
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||'').localeCompare(String(b?.revAuthor||'')),
bonusEventTotal:events=>Object.values(events||{}).reduce((sum,value)=>sum+(value||0),0)
@ -173,28 +176,6 @@ function verifyGlobalMerge(){
'Global timestamped records, encounter memory, or retired combo data were merged incorrectly');
}
async function verifySignalRetry(){
const timers=[];let attempts=0,remembered=0;
const context={
console:{warn:()=>{}},sessionId:'self',worldInitReady:false,deferredWorldSignals:[],
seenWorldCommitIds:new Set(),pendingWorldCommitIds:new Set(),syncQueue:Promise.resolve(),
worldCommitId:signal=>signal.commitId||'',applyWorldSignal:async()=>{attempts++;if(attempts===1)throw new Error('transient')},
rememberWorldCommit:()=>remembered++,
setTimeout:callback=>{timers.push(callback);return timers.length}
};
vm.createContext(context);
vm.runInContext(`${functionSource('queueWorldSignal')}\n${functionSource('drainWorldSignals')}\nthis.queueWorldSignal=queueWorldSignal;this.drainWorldSignals=drainWorldSignals;`,context);
const signal={commitId:'remote:1',sessionId:'remote'};
context.queueWorldSignal(signal);
assert(context.deferredWorldSignals.length===1&&attempts===0,'A startup signal ran before initialization');
context.drainWorldSignals();context.queueWorldSignal(signal);
await context.syncQueue;
assert(attempts===1&&remembered===0&&timers.length===1,
'A failed signal was deduplicated as if it had succeeded');
timers.shift()();await Promise.resolve();await context.syncQueue;
assert(attempts===2&&remembered===1,'A transient signal failure was not retried and committed once');
}
async function verifyDurableBackup(){
const source=functionSource('preserveRecoveryDurably');
const failing={
@ -228,7 +209,6 @@ function verifyRevisionSeed(){
(async()=>{
await verifyPersistenceConflicts();
verifyGlobalMerge();
await verifySignalRetry();
await verifyDurableBackup();
verifyRevisionSeed();
console.log('Concurrency and replacement safety test passed');

View file

@ -0,0 +1,51 @@
'use strict';
const {assert,app,css,functionSource,read}=require('./helpers/app-source');
const browserBenchmark=read('test/browser-performance-benchmark.js');
const scheduler=functionSource('scheduleReactionRender');
assert(app.includes('REACTION_TARGET_FPS=30')&&scheduler.includes('reactionDelayTimer=setTimeout')&&scheduler.includes('requestAnimationFrame')&&scheduler.includes('reactionNextDrawAt'),'Reaction rendering is not deadline-scheduled at the existing 30 FPS cadence');
assert(scheduler.includes("document.visibilityState==='hidden'")&&functionSource('cancelReactionRenderScheduler').includes('reactionLastDraw=0'),'Reaction scheduling is not suspended and reset across hidden-page lifecycle changes');
const prepare=functionSource('prepareReactionModel');
for(const token of ['preparedReactionCracks','preparedLaserRays','preparedOrbitModel','preparedFireworkBloom','preparedReactionBurst'])assert(prepare.includes(token)||app.includes(token),`Prepared reaction model is missing ${token}`);
assert(functionSource('normalizeRealtimeReaction').includes('reaction.prepared=prepareReactionModel(reaction)')&&functionSource('drawStyledReaction').includes('reaction.prepared'),'Immutable reaction work is not prepared once and reused');
assert(app.includes('REACTION_GLYPH_CACHE_MAX_ENTRIES=160')&&app.includes('REACTION_GLYPH_CACHE_MAX_BYTES=12*1024*1024'),'Reaction glyph cache is not explicitly bounded');
assert(app.includes('REACTION_STATIC_PATH_CACHE_MAX_ENTRIES=32')&&functionSource('reactionStaticPath').includes('reactionStaticPathCacheEvictions'),'Reaction static Path2D cache is not bounded or measured');
assert(functionSource('drawOrbitReaction').includes('reactionOrbitRingPath')&&functionSource('drawFireworkReaction').includes('reactionFireworkLaunchPath'),'Static reaction geometry does not use the exact Path2D cache');
const glyph=functionSource('reactionGlyphSprite');
assert(glyph.includes('reactionGlyphCacheHits')&&glyph.includes('reactionGlyphCacheMisses')&&glyph.includes('reactionGlyphCacheEvictions'),'Reaction glyph cache metrics or eviction are missing');
assert(functionSource('drawReactionEmoji').includes('drawImage')&&functionSource('drawReactionEmoji').includes('fillText'),'Cached glyph drawing lacks its exact direct-render fallback');
const reactionLayer=functionSource('drawReactionLayer');
for(const metric of ['reactionFrame','reactionComposite','activeReactions','visibleReactions','peakVisibleReactions','preparedReactionModels','reactionOverload'])assert(app.includes(metric),`Effect performance metric is missing: ${metric}`);
for(const style of ['giant','laser','orbit','firework','comet'])assert(functionSource('drawStyledReaction').includes(`style==='${style}'`),`Full reaction renderer is missing: ${style}`);
assert(!functionSource('drawStyledReaction').match(/interactionActive|lightweightRendering|autoReduced|fallbackStyle|visibleReactionLimit/),'Reaction renderer contains automatic or interaction-driven visual degradation');
assert(reactionLayer.includes('for(const[id,reaction]of realtimeReactions)')&&!reactionLayer.match(/slice\(|break;|visibleReactionLimit/),'Valid overlapping reactions can be dropped from rendering');
const aurora=functionSource('startAuroraRgbAnimation'),auroraState=functionSource('updateAuroraAnimationState');
assert(aurora.includes('AURORA_COLOR_INTERVAL')&&auroraState.includes('auroraVisiblePathCount')&&!aurora.includes('querySelector'),'Aurora is not controlled by tracked visible paths');
assert(functionSource('writeAuroraRgb').includes('auroraColorHost().style.setProperty')&&!functionSource('writeAuroraRgb').includes('document.body.style'),'Aurora color invalidation is not scoped to the world');
assert(functionSource('useInventoryItemLoaded').slice(functionSource('useInventoryItemLoaded').indexOf('if(item.lineColor)'),functionSource('useInventoryItemLoaded').indexOf('if(item.lineEffect)')).includes('renderAll()'),'Equipping a line color does not immediately repaint lines and gates');
assert(app.includes('GEM_PARTICLE_POOL_LIMIT=72')&&functionSource('playGemCollectionAnimation').includes('document.createDocumentFragment()')&&functionSource('releaseGemParticle').includes('gemParticlePool.push'),'Gem animations do not use a bounded, batched node pool');
assert(app.includes('GEM_PARTICLE_KEYFRAME_TEMPLATE=Object.freeze([')&&app.includes('GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE=Object.freeze(')&&functionSource('takeGemParticle').includes('if(!particle._gemKeyframes)')&&functionSource('animateGemParticle').includes('particle.animate(keyframes,options)'),'Gem animation templates are still allocated for every playback or shared unsafely across concurrent particles');
assert(app.includes('COMPLETION_NODE_POOL_LIMIT=16')&&functionSource('finishCompletionVisual').includes('releaseCompletionNode'),'Completion visuals do not return their nodes to a bounded pool');
const inventory=functionSource('renderInventoryPanel');
assert(app.includes('inventoryCategoryViews=new Map()')&&app.includes('inventoryItemViews=new Map()')&&!inventory.includes('inventoryList.replaceChildren()'),'Owned cosmetic rendering still rebuilds the full list');
assert(functionSource('setItemIcon').includes("image.loading='lazy'")&&functionSource('setItemIcon').includes("image.decoding='async'"),'Flag assets are not lazy-loaded and asynchronously decoded');
assert(css.includes('content-visibility:auto')&&css.includes('contain-intrinsic-size'),'Offscreen cosmetic cards do not use render containment');
assert(functionSource('syncInventoryCursorSelection').includes('inventorySelectedCursorItemId')&&!functionSource('syncInventoryCursorSelection').includes('querySelectorAll'),'Cursor equip still scans the entire catalog');
assert(functionSource('renderInventoryPanel').includes('inventoryItemsUnchanged')||functionSource('updateInventoryItemView').includes('inventoryItemsUnchanged'),'Inventory reconciliation does not skip unchanged cards');
const inventoryUse=functionSource('useInventoryItemLoaded');
for(const marker of ['if(item.lineColor)','if(item.reactionStyle)','if(item.scoreLens)'])assert(inventoryUse.slice(inventoryUse.indexOf(marker)).includes('patchInventoryItems'),'Cosmetic equip is not locally patched');
assert(app.includes('renderReactionSample:options=>renderReactionSample(options)')&&functionSource('renderReactionSample').includes('normalizedLife'),'Exact-lifetime visual-equivalence test hook is missing');
for(const gate of ["timing(result.setup.reaction,'reactionPublish').p95<=2","timing(result.setup.gem,'gemEffectSetup').p95<=3","timing(result.setup.completion,'completionEffectSetup').p95<=3","patch.p95<=8","focusRetained","auroraColorWrites","gemBatches===0","effectNodes===0"])assert(browserBenchmark.includes(gate),`Browser release gate is missing: ${gate}`);
for(const interaction of ['benchmark-pickup-effect','benchmark-edge-pan-effect','benchmark-overview-effect'])assert(browserBenchmark.includes(interaction),`Effect interaction probe is missing: ${interaction}`);
assert(!app.includes('autoReducedEffects')&&!app.includes('reactionQualityTier')&&!app.includes('dropReactionForPerformance'),'Automatic effect quality degradation was introduced');
console.log('Effects and cosmetics performance architecture smoke test passed');

View file

@ -7,6 +7,7 @@ const chosen=[],attemptBases=[];
let revision=0,missing=true,hydrations=0;
const context={
metaState:()=>state,
canExpandSharedBoard:()=>true,
hydrateMeta:async target=>{hydrations++;target.puzzle={}},
hydrateAdjacentMetas:async()=>{},
rebuildOccupancy:()=>{},

View file

@ -8,6 +8,8 @@ context.addMetaToOccupancy=meta=>{for(const[dx,dy]of meta.chunks)context.occupan
context.refreshWorldView=options=>{calls.refresh++;if(options?.syncConnections!==false)context.syncBoundaryConnections();context.renderAll();context.updateHud();if(options?.hide)context.hideStatus();return options?.persist?context.save(options.immediate):true};
context.rebuildOccupancy=()=>{context.occupancy=new Map();context.closedVoidKeys=new Set();for(const meta of Object.values(context.data.metas))for(const[dx,dy]of meta.chunks)context.occupancy.set(context.key2(meta.x+dx,meta.y+dy),meta.id);const candidates=new Set();for(const key of context.occupancy.keys()){const[x,y]=key.split(',').map(Number);for(const[dr,dc]of Object.values(context.SIDE_D))candidates.add(context.key2(x+dc,y+dr))}for(const key of candidates){const[x,y]=key.split(',').map(Number);if(!context.occupancy.has(key)&&[...Object.values(context.SIDE_D)].every(([dr,dc])=>context.occupancy.has(context.key2(x+dc,y+dr))))context.closedVoidKeys.add(key)}};
context.metaState=id=>context.data.states[id]||(context.data.states[id]={solved:false,expanded:false,paths:[],specialProgress:{crossings:[]},rev:0});
context.canExpandSharedBoard=()=>true;
context.ensureMetaState=id=>context.metaState(id);
context.unsolvedBoardCount=()=>Object.keys(context.data.metas).filter(id=>!context.metaState(id).solved).length;
vm.createContext(context);
vm.runInContext(`

View file

@ -60,7 +60,7 @@ async function decode(blob,module=persistence){
if(typeof CompressionStream==='function'&&typeof DecompressionStream==='function'){const compressed=await encode('gzip'),damaged=Buffer.from(compressed.bytes);damaged[Math.max(10,Math.floor(damaged.length/2))]^=0xff;let gzipRejected=false;try{await decode(new Blob([damaged]))}catch(_){gzipRejected=true}assert(gzipRejected,'Corrupt gzip archive was accepted')}
const worker=read('field-persistence-worker.js'),moduleSource=read('field-persistence.js');
class FakeWorker{
constructor(){this.onmessage=null;this.onerror=null;const owner=this,context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,self:{postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
constructor(){this.onmessage=null;this.onerror=null;const owner=this,archiveCodec=require('../archive-codec'),context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,importScripts:()=>{},self:{BendArchiveCodec:archiveCodec,postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
postMessage(data){queueMicrotask(()=>{try{this.workerSelf.onmessage({data:structuredClone(data)})}catch(error){this.onerror?.({message:error.message})}})}
terminate(){}
}
@ -68,7 +68,7 @@ async function decode(blob,module=persistence){
const workerEncoded=await encode('identity',workerPersistence),workerDecoded=await decode(workerEncoded.blob,workerPersistence);
assert(workerEncoded.result.worker===true&&workerDecoded.result.worker===true&&workerDecoded.seen.length===2,'Archive worker execution path did not round-trip records');
delete global.Worker;global.BendFieldPersistence=persistence;
assert(worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks)'),'Archive worker does not enforce transferable 1 MiB output chunks');
assert(moduleSource.includes("new Worker('field-persistence-worker.js?v=47.77')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
assert(worker.includes("importScripts('archive-codec.js')")&&worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex(crc)},chunks)'),'Archive worker does not share the codec or enforce transferable 1 MiB output chunks');
assert(moduleSource.includes("new Worker('field-persistence-worker.js')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
console.log('Field archive round-trip and corruption checks passed');
})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -33,6 +33,6 @@ assert(persistNow.includes('result.count>0')&&persistNow.includes('verifyActiveW
assert(batchDelete.includes('getAllKeys(epochKeyRange(epoch),limit)')&&batchDelete.includes("phase:'cleanup'")&&app.includes('GC_BATCH_ROWS=500'),'Epoch garbage collection is not bounded and resumable');
assert(collect.includes('control?.previousEpoch')&&collect.includes("startsWith('pin:')")&&collect.includes("world.status==='ready'")&&collect.includes('cleanupTemporaryExports'),'Garbage collection does not protect rollback/recovery epochs or clean stale ready/export artifacts');
assert(postflight.includes('QUOTA_POSTFLIGHT'),'Import postflight does not recheck browser storage headroom');
assert(app.includes('cameraAnchor')&&app.includes('selectedBoardId')&&init.includes('restoreSavedCamera')&&loadV2.includes('selectedIndex'),'Saved viewport and selected-board startup hints are not restored before the full index scan');
assert(app.includes('cameraAnchor')&&app.includes('selectedBoardId')&&init.includes('randomUnsolvedMeta')&&loadV2.includes('selectedIndex'),'Saved board hints or randomized unsolved-board startup are missing');
assert(persistence.includes('v2Active')&&read('test/browser-field-storage-benchmark.js').includes("'10000,100000,200000'"),'Current V2 persistence or large-field benchmark profiles are missing');
console.log('Current-only field save/load V2 integration guards passed');

View file

@ -0,0 +1,130 @@
{
"version": 1,
"referenceRenderer": "direct-main-thread",
"width": 900,
"height": 700,
"perChannelTolerance": 8,
"maximumChangedPixelRatio": 0.005,
"cases": [
{
"style": "classic",
"life": 0.1
},
{
"style": "classic",
"life": 0.25
},
{
"style": "classic",
"life": 0.5
},
{
"style": "classic",
"life": 0.75
},
{
"style": "classic",
"life": 0.95
},
{
"style": "giant",
"life": 0.1
},
{
"style": "giant",
"life": 0.25
},
{
"style": "giant",
"life": 0.5
},
{
"style": "giant",
"life": 0.75
},
{
"style": "giant",
"life": 0.95
},
{
"style": "laser",
"life": 0.1
},
{
"style": "laser",
"life": 0.25
},
{
"style": "laser",
"life": 0.5
},
{
"style": "laser",
"life": 0.75
},
{
"style": "laser",
"life": 0.95
},
{
"style": "orbit",
"life": 0.1
},
{
"style": "orbit",
"life": 0.25
},
{
"style": "orbit",
"life": 0.5
},
{
"style": "orbit",
"life": 0.75
},
{
"style": "orbit",
"life": 0.95
},
{
"style": "firework",
"life": 0.1
},
{
"style": "firework",
"life": 0.25
},
{
"style": "firework",
"life": 0.5
},
{
"style": "firework",
"life": 0.75
},
{
"style": "firework",
"life": 0.95
},
{
"style": "comet",
"life": 0.1
},
{
"style": "comet",
"life": 0.25
},
{
"style": "comet",
"life": 0.5
},
{
"style": "comet",
"life": 0.75
},
{
"style": "comet",
"life": 0.95
}
]
}

View file

@ -0,0 +1,56 @@
'use strict';
const {assert}=require('./helpers/app-source');
const {createFrameScheduler}=require('../client/input/frame-scheduler');
const {createDragScheduler,STATES}=require('../client/input/drag');
function fakeClock(){
let now=0,nextId=1;
const frames=new Map(),timers=new Map();
return{
now:()=>now,
requestFrame:callback=>{const id=nextId++;frames.set(id,callback);return id},
cancelFrame:id=>frames.delete(id),
setDelay:(callback,delay)=>{const id=nextId++;timers.set(id,{callback,at:now+delay});return id},
clearDelay:id=>timers.delete(id),
advance(milliseconds){
now+=milliseconds;
for(const[id,entry]of[...timers])if(entry.at<=now){timers.delete(id);entry.callback()}
const pending=[...frames];frames.clear();for(const[,callback]of pending)callback(now);
}
};
}
const clock=fakeClock(),commits=[];
const scheduler=createFrameScheduler({
requestFrame:clock.requestFrame,cancelFrame:clock.cancelFrame,setDelay:clock.setDelay,clearDelay:clock.clearDelay,
now:clock.now,interval:1000/60,tolerance:1.25,watchdogDelay:18,
commit:(value,timestamp)=>commits.push({value,timestamp})
});
for(let index=0;index<40;index++){scheduler.push(index);clock.advance(2)}
clock.advance(20);
assert(commits.length<=6,'High-rate input produced more than one presentation commit per nominal 60 Hz interval');
assert(commits.at(-1).value===39,'Latest-value frame scheduling lost the newest input');
for(let index=1;index<commits.length;index++)assert(commits[index].timestamp-commits[index-1].timestamp>=15,'Presentation commits exceeded the 60 Hz tolerance');
const dragClock=fakeClock(),states=[];
const drag=createDragScheduler({
requestFrame:dragClock.requestFrame,cancelFrame:dragClock.cancelFrame,setDelay:dragClock.setDelay,clearDelay:dragClock.clearDelay,
now:dragClock.now,interval:1000/60,tolerance:1.25,watchdogDelay:18,maxSamples:4,
trim:samples=>{while(samples.length>4)samples.splice(1,1)},
onFrame:session=>states.push(session.inspect().state)
});
drag.arm(7);
assert(drag.inspect().state==='armed','Drag did not enter armed state');
for(let index=0;index<8;index++)drag.push({pointerId:7,clientX:index,clientY:index});
assert(drag.inspect().state==='running'&&drag.inspect().logicalCount===4&&drag.latest().clientX===7,'Drag scheduler did not separate bounded logical samples from latest visual input');
dragClock.advance(17);
assert(states[0]==='running','Drag scheduler did not commit through its running frame lane');
assert(drag.beginDrain({pointerId:7,clientX:8,clientY:8})&&drag.inspect().state==='draining','Drag did not enter draining state');
assert(drag.drainLogical().at(-1).clientX===8,'Release drain lost final input intent');
drag.beginSettling();assert(drag.inspect().state==='settling','Drag did not enter settling state');
drag.settle();assert(drag.inspect().state==='idle','Settled drag did not return to idle');
drag.arm(9);drag.cancel();assert(drag.inspect().state==='cancelled','Cancelled drag did not expose its terminal state');
assert(STATES.join(',')==='idle,armed,running,draining,settling,cancelled','Drag lifecycle states changed unexpectedly');
console.log('Shared 60 Hz frame scheduler and explicit drag lifecycle behavior passed');

View file

@ -28,7 +28,7 @@ assert(functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('
for(const marker of ['rawPointerPosition','currentConfirmedCell','candidateDirection','currentCandidateCell','renderedHandlePosition'])assert(functionSource('drawingForPath').includes(marker),`Pointer state is missing ${marker}`);
assert(app.includes('POINTER_SNAP_THRESHOLD=CELL*.45')&&!app.includes('POINTER_SNAP_RELEASE')&&!app.includes('hysteresisDragPoint'),'Single-threshold pointer snapping or hysteresis removal is incomplete');
assert(app.includes('POINTER_DOMINANT_RATIO=1.25')&&!app.includes('POINTER_BUFFER_MS')&&!app.includes('consumeBufferedPointerTurn'),'Speculative one-step turn extension remains active');
assert(functionSource('pointerEventSamples').includes("pointerType!=='mouse'")&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Ordered pointer samples are not preserved through a drag frame');
assert(functionSource('pointerEventSamples').includes("pointerType!=='mouse'")&&functionSource('processBoardDragFrame').includes('scheduler.latest()'),'Ordered pointer samples are not preserved through a drag frame');
assert(!functionSource('extendOne').includes('toast(')&&functionSource('extendOne').includes("if(lock&&!pathHasLockKey(path,lock))return false"),'Invalid direction still produces error feedback');
assert(functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function pickupJoinStepsAtPoint'),'Adjacent path pickups can still auto-connect');
assert(functionSource('openTipMergePlan').includes('seen.has(key)')&&functionSource('joinTips').includes('cancelBoardDragFrame(b)')&&functionSource('joinTips').includes('safeRelease(b.svg,pointerId)'),'Pickup merging can duplicate an overlapping route or retain pointer work after joining');
@ -81,9 +81,9 @@ assert(!html.includes('id="teleportBtn"')&&html.includes('id="minimapOriginBtn"'
assert(functionSource('centerRandomBoard').includes('getRandomValues')&&!app.includes('centerRandomUnsolved'),'Random minimap teleport still cycles through unsolved boards');
assert(functionSource('beginMinimapPointer').includes('centerWorldUnit')&&functionSource('moveMinimapPointer').includes('centerWorldUnit')&&html.includes('id="minimapCanvas" width="210" height="132" role="application" tabindex="0"'),'Direct/keyboard minimap interaction is missing');
assert(functionSource('centerWorldUnit').includes('worldNavigationBounds'),'Minimap teleport is not clamped to world navigation bounds');
assert(functionSource('inWorldOverview').includes('cam.scale<=OVERVIEW_ZOOM_THRESHOLD')&&!app.includes('OVERVIEW_HYSTERESIS')&&css.includes('.static-summary-fill{fill:transparent!important'),'Zoomed board summaries retain sticky green/gray squares');
assert(functionSource('inWorldOverview').includes('cam.scale<=OVERVIEW_ZOOM_THRESHOLD')&&!app.includes('OVERVIEW_HYSTERESIS')&&!app.includes('makeStaticBoard')&&!css.includes('.static-summary'),'Retired zoomed board summaries remain active');
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{')&&css.includes('vector-effect:non-scaling-stroke;opacity:.9'),'Line thickness disappears from an unsolved zoomed-in board');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&functionSource('renderBoardNow').includes('renderedConnectedLineWidth'),'Visible detailed boards do not preserve connected-line thickness');
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!functionSource('rebuildWorldOverviewCache').includes('storeCellForMeta'),'Zoomed-out view no longer matches the minimap renderer');
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('leftFieldPanAllowed(e)'),'Solved/undiscovered left-drag field panning is missing');
@ -92,8 +92,8 @@ vm.createContext(priceContext);
vm.runInContext(`${functionSource('storeItemPrice')}\nthis.storeItemPrice=storeItemPrice;`,priceContext);
assert(priceContext.storeItemPrice({},null,{cost:2500})===3000&&priceContext.storeItemPrice({},null,{cost:10000})===8000,'Store prices do not enforce a 3,000 minimum while retaining higher price variation');
const cursorItems=Array.from({length:12},(_,index)=>({id:`C${index}`,cursorStyle:`face-${index}`})),
otherItems=Array.from({length:1},(_,index)=>({id:`O${index}`})),allStoreItems=[...cursorItems,...otherItems],
const cursorItems=Array.from({length:6},(_,index)=>({id:`C${index}`,cursorStyle:`face-${index}`})),
otherItems=Array.from({length:12},(_,index)=>({id:`O${index}`})),allStoreItems=[...cursorItems,...otherItems],
shopContext={
CURSOR_ITEMS:cursorItems,STORE_ITEMS:allStoreItems,
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle,
@ -104,7 +104,7 @@ vm.runInContext(`${functionSource('normalizeStoreItemIds')}\n${functionSource('s
const seedA=shopContext.shop.seededStoreItemIds(123456),seedARepeat=shopContext.shop.seededStoreItemIds(123456),seedB=shopContext.shop.seededStoreItemIds(654321),
selected=seedA.map(shopContext.storeItem);
assert(JSON.stringify(seedA)===JSON.stringify(seedARepeat)&&JSON.stringify(seedA)!==JSON.stringify(seedB),'Store inventory is not deterministic per field seed');
assert(seedA.length===13&&new Set(seedA).size===13&&selected.filter(item=>item.cursorStyle).length===12&&selected.filter(item=>!item.cursorStyle).length===1,'Seeded store inventory is not exactly twelve cursors and one other item');
assert(seedA.length===12&&new Set(seedA).size===12&&selected.filter(item=>item.cursorStyle).length===6&&selected.filter(item=>!item.cursorStyle).length===6,'Seeded store inventory is not exactly six cursors and six cosmetic/tool items');
assert(JSON.stringify(shopContext.shop.storeInventoryItems({seed:9},{itemIds:seedA}).map(item=>item.id))===JSON.stringify(seedA),'Persisted store inventory IDs are not honored');
// 11-12. Noise/reduced motion and bounded caches/worker.
@ -114,11 +114,11 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
// 13-14. Contextual HUD and active-board isolation.
assert(css.includes('#topbar.drawing-active')&&css.includes(':focus-within')&&css.includes('transition:opacity'),'Contextual HUD fading constraints are missing');
assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.includes('filter:saturate')&&!css.includes('backdrop-filter')&&!css.includes('drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.includes('filter:saturate')&&!css.includes('backdrop-filter')&&!css.includes('.board-card{filter:drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
// 15. Skippable completion independent of persistence.
// 15. Completion is shown only after durable shared-world confirmation.
const solveSource=functionSource('checkSolvedAndExpand');
assert(solveSource.indexOf('completionEffect(immediateBoard,award)')<solveSource.indexOf('persistence=save(true)'),'Completion waits for persistence');
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending(b.id)')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
assert(solveSource.includes('preparation=prepareExpansionCandidate(b.meta)')&&solveSource.includes('expandMeta(durableMeta,prepared)'),'Expansion generation does not start with the clear display or does not install against durable metadata');
assert(solveSource.includes('playGemCollectionAnimation(immediateBoard,award)')&&functionSource('playGemCollectionAnimation').includes('gemCollectionSources')&&functionSource('playGemCollectionAnimation').includes('scoreCountEl'),'Clear rewards do not travel from the board to the gem wallet');
assert(functionSource('skipCompletionVisuals').includes('finishCompletionVisual')&&functionSource('finishCompletionVisual').includes('visual.resolve'),'Completion visual is not independently skippable');
@ -130,7 +130,7 @@ assert(!functionSource('repairExpansions').includes('&&meta.puzzle')&&!functionS
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('function promoteStaticBoard('),'Visible puzzles can still be replaced by on-demand summaries');
assert(app.includes('HYDRATE_CONCURRENCY=4')&&functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.size>32')&&functionSource('evictHydratedBoardDetails').includes('hydratedBoardBytes>8*1024*1024')&&app.includes('BOARD_RENDERS_PER_FRAME=6'),'Hydration, cache, or render work is not bounded');
assert(functionSource('drawPresenceLayer').includes('dpr=1')&&functionSource('drawReactionLayer').includes('dpr=1'),'Fullscreen online canvases still allocate high-DPR backing stores');
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'FPS diagnostics or split interaction budgets are missing');
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'FPS diagnostics or split interaction budgets are missing');
assert(functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Noise background still repaints continuously');
// 17. Bounded interaction burden in candidate selection.

View file

@ -4,7 +4,8 @@ const path=require('path');
const vm=require('vm');
const root=path.resolve(__dirname,'../..');
const read=name=>fs.readFileSync(path.join(root,name),'utf8');
const app=read('app.js'),html=read('index.html'),css=read('style.css'),worker=read('puzzle-worker.js'),appLogicSource=read('app-logic.js');
const app=read('app.js'),html=read('index.html'),css=`${read('style.css')}\n${read('client/styles/tokens.css')}\n${read('client/styles/base.css')}\n${read('client/ui/cursor.css')}\n${read('client/styles/accessibility.css')}`,worker=read('puzzle-worker.js'),appLogicSource=read('app-logic.js'),buildMetaSource=read('build-meta.js');
const buildMeta=require(path.join(root,'build-meta.js'));
function assert(value,message){if(!value)throw new Error(message)}
function functionSource(name,source=app){
let start=source.indexOf(`async function ${name}(`);if(start<0)start=source.indexOf(`function* ${name}(`);if(start<0)start=source.indexOf(`function ${name}(`);
@ -43,4 +44,4 @@ function starterPuzzle(){
assert(match,'Bundled origin puzzle missing');
return JSON.parse(match[1]);
}
module.exports={root,read,app,html,css,worker,appLogicSource,assert,functionSource,createContext,runFunctions,loadBendPuzzle,loadAppLogic,starterPuzzle,vm,path,fs};
module.exports={root,read,app,html,css,worker,appLogicSource,buildMetaSource,buildMeta,assert,functionSource,createContext,runFunctions,loadBendPuzzle,loadAppLogic,starterPuzzle,vm,path,fs};

View file

@ -0,0 +1,33 @@
'use strict';
const {assert,read}=require('./helpers/app-source');
const {createGestureCoordinator}=require('../client/input/gesture-coordinator');
const {createInteractionState}=require('../client/input/interaction-state');
const coordinator=createGestureCoordinator();
assert(coordinator.claim(1,'pan'),'Pan did not claim an idle pointer');
assert(!coordinator.claim(1,'wheel'),'Lower-precedence wheel input displaced pan ownership');
assert(coordinator.claim(1,'reaction')&&coordinator.owner(1)==='reaction','Reaction did not take precedence over pan');
assert(coordinator.claim(1,'draw')&&coordinator.owner(1)==='draw','Draw did not take precedence over reaction');
assert(!coordinator.release(1,'pan')&&coordinator.owns(1,'draw'),'A non-owner released the drawing pointer');
assert(coordinator.release(1,'draw')&&!coordinator.owner(1),'The drawing pointer did not return to idle');
assert(coordinator.claim(2,'pan')&&coordinator.claim(3,'pan'),'Independent pointers could not be owned');
assert(coordinator.claim(2,'pinch')&&coordinator.claim(3,'pinch'),'Pinch did not transfer both touch pointers');
coordinator.cancelAll('blur');
assert(!coordinator.owner(2)&&!coordinator.owner(3),'Blur cancellation leaked pointer ownership');
const interactions=createInteractionState();let notifications=0;
const unsubscribe=interactions.subscribe(()=>notifications++);
interactions.set('camera','pan:1');
assert(interactions.active('camera')&&interactions.active('world')&&!interactions.active('worker'),'Camera interaction scopes are incorrect');
interactions.set('drawing','draw:2');
assert(interactions.active('worker')&&interactions.active('persistence'),'Drawing interaction scopes are incorrect');
interactions.clear('camera');interactions.clear('drawing');unsubscribe();
assert(!interactions.active()&&notifications===4,'Interaction state did not settle or notify exactly once per transition');
const app=read('app.js'),html=read('index.html');
assert(html.indexOf('client/input/frame-scheduler.js')<html.indexOf('client/input/drag.js')&&html.indexOf('client/input/drag.js')<html.indexOf('app.js')&&html.indexOf('client/input/interaction-state.js')<html.indexOf('app.js')&&html.indexOf('client/input/gesture-coordinator.js')<html.indexOf('app.js'),'Interaction ownership modules are not loaded before the application');
const bodyReads=[...app.matchAll(/classList(?:\?\.)?\.contains(?:\?\.)?\(['"]is-interacting['"]\)/g)];
assert(bodyReads.length===1&&app.includes("document.body.classList.toggle('is-interacting',active)"),'The DOM interaction class is still used as application state');
console.log('Gesture ownership and scoped interaction-state behavior passed');

View file

@ -1,5 +1,6 @@
'use strict';
const {vm,assert,functionSource,loadAppLogic,app,css}=require('./helpers/app-source');
const {createFrameScheduler}=require('../client/input/frame-scheduler');
const AppLogic=loadAppLogic();
const context={PAD:16,CELL:40,ckey:(r,c)=>`${r},${c}`};
vm.createContext(context);
@ -38,15 +39,16 @@ const centerVelocity=gestureContext.gesture.edgePanVelocity(500,400),rightVeloci
assert(centerVelocity[0]===0&&centerVelocity[1]===0&&rightVelocity[0]>0,'Edge auto-pan velocity is not limited to the viewport edge');
console.log('Direct snap threshold, outward gate reach, and edge auto-pan test passed');
const sampleContext={perfNow:()=>100,DRAG_MAX_POINTER_SAMPLES:12};
let capturedPointerSample=null;
const sampleContext={perfNow:()=>100,DRAG_MAX_POINTER_SAMPLES:12,ensureBoardDragScheduler:()=>({push:sample=>{capturedPointerSample=sample;return 1}})};
vm.createContext(sampleContext);
vm.runInContext(`${functionSource('pointerEventSamples')}\n${functionSource('trimBoardPointerSamples')}\n${functionSource('setBoardPointerSample')}\n${functionSource('appendBoardPointerSamples')}\nthis.pointer={pointerEventSamples,appendBoardPointerSamples};`,sampleContext);
const cornerSamples=[
{clientX:220,clientY:280},{clientX:140,clientY:280},{clientX:140,clientY:196},
{clientX:290,clientY:196},{clientX:290,clientY:250}
],sampleBoard={pendingPointerMove:null};
],sampleBoard={};
sampleContext.pointer.appendBoardPointerSamples(sampleBoard,{pointerId:12,pointerType:'pen',getCoalescedEvents:()=>cornerSamples});
assert(JSON.stringify([sampleBoard.pendingPointerMove.clientX,sampleBoard.pendingPointerMove.clientY])===JSON.stringify([290,250])&&sampleBoard.pointerMoveSamples.length===1,'Pointer coalescing did not retain the newest sample in the bounded logic queue');
assert(JSON.stringify([capturedPointerSample.clientX,capturedPointerSample.clientY])===JSON.stringify([290,250]),'Pointer coalescing did not retain the newest sample in the bounded logic queue');
const threeTurnPath={startGate:0,endGate:null,cells:[[4,6],[4,5],[4,4],[4,3],[4,2],[4,1],[3,1],[2,1],[2,2],[2,3],[3,3]]};
assert(AppLogic.analyzePathTurns(threeTurnPath,[[4,6,'E']],[],false).count===3,'The pictured left-up-right-down pickup gesture does not display three turns');
assert(!functionSource('extendPointerTo').includes('consumeBufferedPointerTurn'),'Pickup drag still extends a speculative second cell');
@ -83,7 +85,7 @@ const mergeContext={
metaState:()=>mergeState,manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),ckey:(r,c)=>`${r},${c}`,cellSet:p=>p.validSet,
cancelBoardDragFrame:()=>cancelledMergeFrames++,
applyBoardCommand:(_board,mutate)=>mutate()===false?false:true,
safeRelease:()=>releasedMergePointers++,playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
safeRelease:()=>releasedMergePointers++,commitConnectedLineVisuals:()=>{},playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
};
vm.createContext(mergeContext);
vm.runInContext(`${functionSource('openTipMergePlan')}\n${functionSource('joinTips')}\nthis.joinTips=joinTips;`,mergeContext);
@ -96,15 +98,20 @@ assert(!app.includes('function pickupJoinStepsAtPoint')&&!app.includes('function
let cameraCallback=null,cameraTimerCallback=null,cameraApplies=0;
const cameraContext={
CAMERA_DISPLAY_WATCHDOG_MS:18,DRAG_FRAME_INTERVAL:1000/60,
cam:{x:0,y:0,scale:1},cameraInteractionFrame:0,cameraInteractionDelayTimer:0,cameraInteractionLastDraw:0,pendingCameraInteraction:null,
cam:{x:0,y:0,scale:1},pendingCameraInteraction:null,
requestAnimationFrame:callback=>{cameraCallback=callback;return 7},cancelAnimationFrame:()=>{},setTimeout:callback=>{cameraTimerCallback=callback;return 8},clearTimeout:()=>{},applyCamera:immediate=>{assert(immediate===true,'Queued camera update was not committed directly')},
perfNow:()=>100,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{},recordInteractionCommit:()=>{},observeInteractionFrame:()=>{},data:{cameraAnchor:null},currentCameraAnchor:()=>({centerX:0,centerY:0,scale:1}),markGlobalDirty:()=>{},
};
vm.createContext(cameraContext);
vm.runInContext(`${functionSource('commitCameraInteraction')}\n${functionSource('queueCameraInteraction')}\nthis.queueCameraInteraction=queueCameraInteraction;`,cameraContext);
vm.runInContext(`${functionSource('commitCameraInteraction')}\nthis.commitCameraInteraction=commitCameraInteraction;`,cameraContext);
cameraContext.cameraInteractionScheduler=createFrameScheduler({
requestFrame:cameraContext.requestAnimationFrame,cancelFrame:cameraContext.cancelAnimationFrame,setDelay:cameraContext.setTimeout,clearDelay:cameraContext.clearTimeout,
now:cameraContext.perfNow,interval:1000/60,tolerance:1.25,watchdogDelay:18,commit:(next,timestamp)=>cameraContext.commitCameraInteraction(next,timestamp)
});
vm.runInContext(`${functionSource('queueCameraInteraction')}\nthis.queueCameraInteraction=queueCameraInteraction;`,cameraContext);
cameraContext.applyCamera=()=>cameraApplies++;
cameraContext.queueCameraInteraction({x:10,y:20,scale:1});cameraContext.queueCameraInteraction({x:30,y:40,scale:1});
assert(cameraContext.cameraInteractionFrame===7&&cameraContext.cam.x===0,'Raw pan events were applied before the animation frame');
assert(cameraContext.cameraInteractionScheduler.inspect().armed&&cameraContext.cam.x===0,'Raw pan events were applied before the animation frame');
cameraCallback();
assert(cameraContext.cam.x===30&&cameraContext.cam.y===40&&cameraApplies===1,'Pan events were not coalesced to the latest frame');
cameraTimerCallback();
@ -151,19 +158,21 @@ assert(resetState.paths.length===0&&resetState.specialProgress.crossings.length=
assert(resetRenderCount===1&&resetChangeCount===1&&resetSyncCount===0,'Reset was not immediately rendered and persisted exactly once');
console.log('First-click reset test passed');
const shopItems=[{id:'O1'},{id:'O2'},...Array.from({length:12},(_,index)=>({id:`C${index+1}`,cursorStyle:`face-${index+1}`}))];
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("'store-cursor':'store-other'"),'Shop rendering bypasses the fixed 2+12 item inventory');
const shopItems=[...Array.from({length:12},(_,index)=>({id:`O${index+1}`})),...Array.from({length:6},(_,index)=>({id:`C${index+1}`,cursorStyle:`face-${index+1}`}))];
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("category.compact?' store-compact'"),'Shop rendering bypasses the fixed 12+6 item inventory or compact cosmetic layout');
const storeRateContext={
STORE_CHANCE:.10,hash32:value=>value>>>0,LOCAL_SOLVER:'tester',STORE_PRICE_VERSION:1,SCORE_VERSION:3,
trustedNow:()=>1000,currentPlayerName:()=>"tester",seededStoreItemIds:()=>shopItems.map(item=>item.id),storePriceDetails:()=>({coefficient:1})
trustedNow:()=>1000,currentPlayerName:()=>"tester",seededStoreItemIds:()=>shopItems.map(item=>item.id),storePriceDetails:()=>({coefficient:1}),puzzleOf:meta=>meta.puzzle
};
vm.createContext(storeRateContext);
vm.runInContext(`${functionSource('maybeOpenStore')}\nthis.maybeOpenStore=maybeOpenStore;`,storeRateContext);
vm.runInContext(`${functionSource('storeObstacleCell')}\n${functionSource('maybeOpenStore')}\nthis.maybeOpenStore=maybeOpenStore;`,storeRateContext);
const eligibleStoreState=()=>({solved:true,store:null,paths:[{cells:[[0,0],[0,1]]}]});
const storeMeta={seed:1,puzzle:{obstacles:[[1,1],[2,2]]}};
const belowThreshold=eligibleStoreState(),atThreshold=eligibleStoreState();
storeRateContext.maybeOpenStore({seed:1},belowThreshold,0,.099999);
storeRateContext.maybeOpenStore({seed:1},atThreshold,0,.10);
storeRateContext.maybeOpenStore(storeMeta,belowThreshold,0,.099999);
storeRateContext.maybeOpenStore(storeMeta,atThreshold,0,.10);
assert(belowThreshold.store&&atThreshold.store===null,'Shop appearance boundary is not exactly 1/10');
assert(storeMeta.puzzle.obstacles.some(cell=>cell[0]===belowThreshold.store.cell[0]&&cell[1]===belowThreshold.store.cell[1]),'Shop did not replace an obstacle cell');
assert(!app.includes('function overviewShopAtClient(')&&!functionSource('beginPan').includes('overviewShopAtClient'),'Zoomed-out view still has a shop-only hit target absent from the minimap');
@ -199,7 +208,7 @@ const detachedPath=detachedState.paths[0];
assert(detachedPath.detachedStart&&JSON.stringify(detachedPath.cells)==='[[0,2],[0,1],[0,0]]','Detached line does not retain two oriented edge pickups');
assert(detachedContext.detached.pathUsesGate(detachedPath,0)&&detachedBoard.drawing.pointerId===19,'New gate-side pickup is not associated with the active drag');
assert(!functionSource('renderBoardNow').includes("whitePickupEnd")&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'"),'Two-ended line does not render both colored pickup handles');
const normalizeDetachedContext={isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),LINE_COLORS:Array(10).fill('#000')};
const normalizeDetachedContext={isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),LINE_COLORS:Array(10).fill('#000'),LINE_EFFECT_IDS:new Set(['glow','neon'])};
vm.createContext(normalizeDetachedContext);
vm.runInContext(`${functionSource('normalizePath')}\nthis.normalizePath=normalizePath;`,normalizeDetachedContext);
const normalizedDetached=normalizeDetachedContext.normalizePath(detachedPath);
@ -232,7 +241,8 @@ const reconnectContext={
metaState:()=>reconnectState,usedGateSet:()=>new Set(),LINE_COLORS:['#0af','#fa0','#0f0'],canonicalGateColorIndex:()=>1,
applyBoardCommand:(_board,mutate)=>{mutate();return true},neighborColor:()=>null,playSound:()=>{},
usesLightweightDragOverlay:()=>false,requestAnimationFrame:callback=>callback(),
gateConnectEffect:(_board,_gate,color)=>gateEffects.push(color),queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
gateConnectEffect:(_board,_gate,color)=>gateEffects.push(color),queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{},
commitConnectedLineVisuals:()=>{},cancelBoardDragFrame:()=>{},clearDragRender:()=>{},hidePickupHandleOverlay:()=>{},safeRelease:()=>{}
};
vm.createContext(reconnectContext);
vm.runInContext(`${functionSource('finalizeAtGate')}\nthis.finalizeAtGate=finalizeAtGate;`,reconnectContext);
@ -279,7 +289,7 @@ console.log('Connected-gate dragging and two-handle line disappearance tests pas
assert(!app.includes('RETRACTION_HOLD_MS')&&!app.includes('pendingRetraction'),'Own-line retraction still has a delayed state');
assert(functionSource('extendOne').includes('path.cells.splice(rewind+1)')&&functionSource('extendPointerTo').includes('rewindActivePathToCell(b,targetCell'),'Original immediate own-line shortening was not restored');
assert(functionSource('gateFromPointOrCell').includes('maxPixels:0')&&!functionSource('extendPointerTo').includes('gateConnectionSteps'),'A knob can still snap to a gate from an adjacent cell');
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!functionSource('bindBoard').includes('centerMeta(')&&!app.includes('function promoteStaticBoard('),'Board input can still promote a summary or teleport the camera');
assert(!app.includes('shouldTeleportToUnsolvedBoard')&&!functionSource('bindBoard').includes('centerMeta(')&&!app.includes('function promoteStaticBoard('),'Board input can still promote a summary or teleport the camera');
assert(functionSource('matchingNumberKeys').includes('partialTurnCount')&&functionSource('updateNumberMatchFeedback').includes("classList.toggle('number-match',current.size>0)")&&functionSource('updateNumberMatchFeedback').includes("matchOrbit.classList.toggle('show'")&&css.includes('@keyframes numberMatchOrbit')&&css.includes('stroke-dasharray:1.2 5.35'),'Matching turn-number feedback does not keep an enlarged knob with a rotating perforated orbit');
assert(functionSource('renderBoardNow').includes("'\\u66f2\\u304c\\u308b'")&&app.includes('numberLayer.append(node,warning)')&&css.includes('.number-turn-warning.show{opacity:1}'),'The 曲がる warning is not prominent or is behind the number');
console.log('Immediate retraction, exact-cell gate, no-click-teleport, and number-match feedback guards passed');

View file

@ -31,7 +31,7 @@ assert(minimapBuildSource.includes('minimapCache={revision:minimapWorldRevision'
assert(functionSource('drawMapLongLines').includes('minimapGeometryForComponent(component,caches.geometries)'),'Long-line minimap geometry is recalculated during every world-layer rebuild');
assert(functionSource('markStateDirty').includes('invalidateWorldPresentation()'),'State changes do not invalidate minimap/overview content');
assert(app.includes('globalThis.BEND_PERF=')&&functionSource('perfObserve').includes('samples.length>240'),'Performance measurements are missing or unbounded');
assert(css.includes('#noiseCanvas.interaction-muted')&&css.includes('body.reduced-effects #noiseCanvas')&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Decorative noise is not static or interaction/reduced-motion suppression is missing');
assert(!css.includes('#noiseCanvas.interaction-muted')&&css.includes('body.reduced-effects #noiseCanvas')&&functionSource('applyUiSettings').includes("uiSettings.lightweightRendering")&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Decorative noise is not static or the explicit lightweight setting is not the only suppression path');
assert((ensureBoardsSource.match(/changes<LOD_CHANGES_PER_PASS/g)||[]).length>=3&&ensureBoardsSource.includes('if(pending)scheduleLodPass()'),'LOD creation or eviction bypasses the per-pass budget');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible-puzzle detail selection is incomplete or still budget-culls boards');
assert(ensureBoardsSource.includes("perfGauge('visibleUnsolvedBoards'"),'LOD performance telemetry cannot prove visible unsolved-board coverage');
@ -113,7 +113,7 @@ const mapContext2d={setTransform(){},clearRect(){},drawImage(){drawCopies++},beg
minimapCache:{revision:1,width:210,height:132,dpr:1,anchorX:0,anchorY:0,scale:5,overscanPixels:50,baseWidth:310,baseHeight:232,longSegments:0},
window:{devicePixelRatio:1},MINIMAP_VIEW_CHUNKS_X:42,data:{metas:{}},getMinimapRect:()=>({width:210,height:132}),
cameraCenterInChunks:()=>center,visibleMetaIdsForBounds:()=>new Set(),metaState:()=>({solved:false}),
perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
interactionActive:()=>false,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
};
minimapContext.rebuildMinimapWorld=(width,height,dpr,x,y)=>{
minimapBuilds++;minimapContext.minimapCache={...minimapContext.minimapCache,revision:minimapContext.minimapWorldRevision,width,height,dpr,anchorX:x,anchorY:y};
@ -124,28 +124,26 @@ minimapContext.drawMinimap();assert(minimapBuilds===0&&drawCopies===1,'Small cam
center=[20,0];minimapContext.drawMinimap();assert(minimapBuilds===1&&drawCopies===2,'Camera movement beyond minimap overscan did not rebuild exactly once');
let overviewLodSchedules=0;
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}])),
overviewStatic=new Map(Array.from({length:6},(_,index)=>[`S${index}`,{id:`S${index}`}]));
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}]));
const overviewLodContext={
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,staticRendered:overviewStatic,inWorldOverview:()=>true,
destroyBoard:board=>overviewRendered.delete(board.id),destroyStaticBoard:board=>overviewStatic.delete(board.id),
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,inWorldOverview:()=>true,interactionActive:()=>false,
destroyBoard:board=>overviewRendered.delete(board.id),
scheduleLodPass:()=>overviewLodSchedules++,scheduleWorldOverview:()=>{},perfStart:()=>0,perfCount:()=>{},perfEnd:()=>{}
};
vm.createContext(overviewLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,overviewLodContext);overviewLodContext.ensureBoards();
assert(overviewRendered.size+overviewStatic.size===8&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
assert(overviewRendered.size===2&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
let creationLodSchedules=0;
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),creationStatic=new Map(),
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),
creationData={metas:Object.fromEntries([...visibleIds].map(id=>[id,{id,puzzle:{}}]))};
const creationLodContext={
LOD_CHANGES_PER_PASS:4,rendered:new Map(),staticRendered:creationStatic,data:creationData,inWorldOverview:()=>false,
LOD_CHANGES_PER_PASS:4,rendered:new Map(),data:creationData,inWorldOverview:()=>false,interactionActive:()=>false,
visibleMetaIds:()=>visibleIds,desiredInteractiveBoardIds:ids=>new Set(ids),metaState:()=>({solved:false}),
makeStaticBoard:meta=>creationStatic.set(meta.id,{id:meta.id,signature:'current'}),destroyStaticBoard:board=>board&&creationStatic.delete(board.id),
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),staticBoardSignature:()=> 'current',
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),
scheduleLodPass:()=>creationLodSchedules++,perfStart:()=>0,perfCount:()=>{},perfGauge:()=>{},perfEnd:()=>{}
};
vm.createContext(creationLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,creationLodContext);creationLodContext.ensureBoards();
assert(creationLodContext.rendered.size===4&&creationStatic.size===0&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards behind static summaries');
assert(creationLodContext.rendered.size===4&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards');
(async()=>{
let workerConstructions=0,workerNow=1000,timerId=0;

View file

@ -15,10 +15,10 @@ assert(css.includes('#presenceCanvas')&&css.includes('.board-claim-badge'),'Pres
assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&app.includes('REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000')&&app.includes('AUXILIARY_FRAME_INTERVAL'),'Cursor transport is not capped at 20 Hz or presence drawing is not tied to the auxiliary frame budget');
assert(functionSource('scheduleRealtimeViewport').includes('REALTIME_VIEWPORT_INTERVAL'),'Viewport subscription is not throttled');
assert(functionSource('drawPresenceLayer').includes('remotePlayers')&&functionSource('drawPresenceLayer').includes('scheduleMinimap'),'Remote cursors are not rendered through the shared canvas layer');
assert(functionSource('drawMinimap').includes('nearbyPlayers'),'Remote players are missing from the minimap');
assert(functionSource('requestBoardClaim').includes("type:'claim'")&&functionSource('touchBoardClaim').includes("type:'claim-touch'"),'Client claim lease messages are incomplete');
assert(functionSource('drawMinimap').includes('remotePlayers'),'Remote players are missing from the minimap');
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes("type:'claim'")&&functionSource('touchBoardClaim').includes("type:'claim-touch'"),'Client claim lease requests are incomplete');
assert(server.includes("status:423")&&server.includes('hasClaim(player.playerId,rawRow.id)'),'Server clear validation does not require the active claimant');
assert(realtime.includes('5 * 60 * 1000')&&realtime.includes("releaseBoardClaim(boardId, 'moved')")&&realtime.includes("reason:'expired'"),'Five-minute lease or board-switch release behavior is missing');
assert(realtime.includes("message.type === 'viewport'")&&realtime.includes("message.type === 'cursor-hide'")&&realtime.includes('pointInViewport'),'Realtime fan-out is not viewport-filtered');
assert(!Object.keys(pkg.dependencies||{}).length&&!Object.keys(pkg.devDependencies||{}).length,'Phase 2 added an unnecessary runtime dependency');
assert(!Object.keys(pkg.dependencies||{}).length&&Object.keys(pkg.devDependencies||{}).every(name=>name==='playwright-core'),'Phase 2 added an unnecessary runtime dependency');
console.log('Shared-world phase 2 source guards passed');

View file

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

View file

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

View file

@ -23,10 +23,11 @@ const {connectRealtime}=require('./helpers/realtime-client');
alice.send({type:'viewport',minX:-2,minY:-2,maxX:2,maxY:2});bob.send({type:'viewport',minX:-2,minY:-2,maxX:2,maxY:2});await alice.waitFor('snapshot');await bob.waitFor('snapshot');
alice.send({type:'claim',requestId:'lease-a',boardId:'B0'});assert.equal((await alice.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-a')).ok,true);await bob.waitFor(message=>message.type==='claim'&&message.claim?.boardId==='B0');
alice.close();alice=null;
bob.send({type:'claim',requestId:'lease-b-early',boardId:'B0'});const early=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-early');assert.equal(early.ok,false);assert.equal(early.reason,'occupied');
await bob.waitFor(message=>message.type==='claim-release'&&message.boardId==='B0'&&message.reason==='disconnected');
bob.send({type:'claim',requestId:'lease-b-after-disconnect',boardId:'B0'});const transferred=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-after-disconnect');assert.equal(transferred.ok,true);
await new Promise(resolve=>setTimeout(resolve,170));bob.send({type:'snapshot-request'});await bob.waitFor(message=>message.type==='claim-release'&&message.boardId==='B0'&&message.reason==='expired');
bob.send({type:'claim',requestId:'lease-b-late',boardId:'B0'});const late=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-late');assert.equal(late.ok,true);
console.log('Realtime lease expiry and disconnect semantics passed');
console.log('Realtime lease expiry and disconnect release semantics passed');
}finally{
alice?.close();bob?.close();hub.close();await new Promise(resolve=>server.close(resolve));
}

View file

@ -10,7 +10,7 @@ const {connectRealtime}=require('./helpers/realtime-client');
const port=20000+Math.floor(Math.random()*10000);
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-realtime-phase2-'));
const child=spawn(process.execPath,[path.join(root,'server.js')],{
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']
});
let stderr='',aliceRealtime=null,bobRealtime=null;child.stderr.on('data',chunk=>stderr+=chunk);
const base=`http://127.0.0.1:${port}`;
@ -26,9 +26,12 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,111,puzzle),b1=boardMeta('B1',2,222,puzzle);
const bootstrap=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:2},metas:[b0,b1],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}},{id:'B1',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});
const bootstrap=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'linkfield-single-world-20260801',nextId:2},metas:[b0,b1],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}},{id:'B1',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});
assert.equal(bootstrap.response.status,200);assert.equal(bootstrap.body.revision,1);
const directClaim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0'})});assert.equal(directClaim.response.status,200);assert.equal(directClaim.body.ok,true);assert.equal(directClaim.body.claim.playerName,'Alice');
const directDenied=await request('/api/realtime/claim',{method:'POST',headers:auth(bob),body:JSON.stringify({boardId:'B0'})});assert.equal(directDenied.response.status,200);assert.equal(directDenied.body.ok,false);assert.equal(directDenied.body.reason,'occupied');
aliceRealtime=await connectRealtime(base,alice);bobRealtime=await connectRealtime(base,bob);
aliceRealtime.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});bobRealtime.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});
await aliceRealtime.waitFor('snapshot');await bobRealtime.waitFor('snapshot');
@ -63,8 +66,9 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
await bobRealtime.waitFor(message=>message.type==='claim'&&message.claim?.boardId==='B1');
aliceRealtime.close();aliceRealtime=null;
await bobRealtime.waitFor(message=>message.type==='player-left'&&message.playerId===alice.playerId);
await bobRealtime.waitFor(message=>message.type==='claim-release'&&message.boardId==='B1'&&message.reason==='disconnected');
bobRealtime.send({type:'claim',requestId:'bob-b1-after-disconnect',boardId:'B1'});
const retained=await bobRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='bob-b1-after-disconnect');assert.equal(retained.ok,false);assert.equal(retained.reason,'occupied');
const transferred=await bobRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='bob-b1-after-disconnect');assert.equal(transferred.ok,true);assert.equal(transferred.claim.playerName,'Bob');
console.log('BEND FIELD realtime phase 2 smoke test passed');
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceRealtime?.close();bobRealtime?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});

View file

@ -1,5 +1,5 @@
'use strict';
const {vm,app,assert,functionSource}=require('./helpers/app-source');
const {vm,app,assert,functionSource,buildMeta}=require('./helpers/app-source');
const removed=[],deleted=[];
const staleSessionJournal='bend-field:v30:v47-field-reset-20260727:journal:stale-session',retired=[
{schema:31,generation:'v47-field-reset-20260728-bugfix'},
@ -20,5 +20,5 @@ for(const item of retired){
assert(deleted.includes(`${prefix}:world`),'Old IndexedDB was not deleted');
}
assert(removed.includes(staleSessionJournal),'Old per-session recovery journal was not removed');
assert(app.includes("SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'"),'v47.36 interaction-fix field reset generation is not active');
assert(buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801','Seed-refresh field reset generation is not active');
console.log('Full field reset test passed');

View file

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

View file

@ -15,7 +15,7 @@ assert(pushCloudSource.includes('mergeCloudPending(cloudPushPending,pending)')&&
const batchContext={};vm.createContext(batchContext);vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('takeCloudPendingBatch')}\nthis.takeCloudPendingBatch=takeCloudPendingBatch;`,batchContext);
const source={metaIds:new Set(Array.from({length:600},(_,index)=>`B${index}`)),stateIds:new Set(['B0']),deleted:new Set(['B999']),globalChanged:true},
{batch,remainder}=batchContext.takeCloudPendingBatch(source,512);
assert(batch.metaIds.size===512&&remainder.metaIds.size===88&&remainder.stateIds.has('B0')&&remainder.deleted.has('B999')&&batch.globalChanged,'Cloud change batching lost or exceeded work');
assert(batch.metaIds.size===1&&batch.metaIds.has('B0')&&batch.stateIds.has('B0')&&remainder.metaIds.size===599&&batch.deleted.has('B999')&&batch.globalChanged,'Cloud batching did not isolate one complete board mutation');
}
const writes={meta:0,state:0,global:0,outbox:0,recovery:0,mirror:0,snapshot:0,checkpoint:0,revision:0,journalClear:0,signal:0,cloud:0};
@ -34,7 +34,7 @@ const context={
deletedBoardAuthors:new Map(),
recoveryJournalsToCover:[{sessionId:'prior-session',seq:6,_storageKey:'journal:prior'}],recoveryJournalSeq:3,
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),cloudJournalDeletedIds:new Set(),cloudApiEnabled:false,
globalDirty:true,globalChangeSeq:3,cloudJournalGlobalChanged:true,worldSignalSeq:0,idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,storageKey:'save',
globalDirty:true,globalChangeSeq:3,cloudJournalGlobalChanged:true,idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,storageKey:'save',
pruneAndCount:()=>{},hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test-epoch',rememberWorldEpoch:()=>true,
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),newerRevisionValue:(a,b)=>((a?.rev||0)>=(b?.rev||0)?a:b),
@ -53,7 +53,8 @@ const context={
writeCompactMirror:snapshot=>{assert(snapshot.updatedAt===123456,'Mirror did not use the captured persistence snapshot');writes.mirror++;return true},safeLocalSet:()=>{writes.mirror++;return true},
scheduleMirrorCheckpoint:()=>writes.checkpoint++,
updateStorageRevision:()=>writes.revision++,clearRecoveryJournalIfCovered:(seq,covered)=>{writes.journalClear++;journalClears.push({seq,covered})},
broadcastWorldSignal:()=>writes.signal++,scheduleCloudPush:()=>writes.cloud++,
scheduleCloudPush:()=>writes.cloud++,
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{},deepClone:value=>JSON.parse(JSON.stringify(value)),resetHistory:[],invalidateStoreEffectCache:()=>{},statsDirty:false
};
vm.createContext(context);
@ -93,7 +94,7 @@ vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,cont
let scheduled=0,immediate=0;
const saveContext={
Promise,SAVE_DELAY:180,saveTimer:null,lifecyclePersistenceSuppressed:false,
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},runDeferredSave:()=>{},
setSaveStatus:()=>{},clearTimeout:()=>{},setTimeout:()=>{scheduled++;return 1}
};
vm.createContext(saveContext);vm.runInContext(`${saveSource}\nthis.save=save;`,saveContext);

View file

@ -1,14 +1,15 @@
'use strict';
const {spawn}=require('child_process');const fs=require('fs');const os=require('os');const path=require('path');const assert=require('assert/strict');
const {root,starterPuzzle}=require('./helpers/app-source');const {connectRealtime}=require('./helpers/realtime-client');
const port=32000+Math.floor(Math.random()*2000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-auth-')),child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']});let stderr='',ws;child.stderr.on('data',c=>stderr+=c);const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(r=>setTimeout(r,ms));
const port=32000+Math.floor(Math.random()*2000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-auth-')),child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']});let stderr='',ws;child.stderr.on('data',c=>stderr+=c);const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function req(url,opt={}){const response=await fetch(base+url,opt);return{response,body:await response.json()}}function auth(s){return{authorization:`Bearer ${s.playerId}.${s.token}`,'content-type':'application/json'}}
function meta(id,x,seed,p){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle:p,rev:1,revAuthor:'x'}}
(async()=>{for(let i=0;i<100;i++){try{if((await req('/api/cloud/status')).response.ok)break}catch{}await sleep(30)}const alice=(await req('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body,p=starterPuzzle(),b0=meta('B0',0,15,p);
(async()=>{for(let i=0;i<100;i++){try{if((await req('/api/cloud/status')).response.ok)break}catch{}await sleep(30)}const alice=(await req('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body,p=starterPuzzle(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];p.g=[[0,1,'N'],[4,2,'S']];p.n=[[0,0,16]];p.valid=route.map(cell=>[...cell]);p.obstacles=[[2,2]];p.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];p.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};const b0=meta('B0',0,15,p);
let r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,200);ws=await connectRealtime(base,alice);ws.send({type:'viewport',minX:-5,minY:-5,maxX:5,maxY:5});await ws.waitFor('snapshot');ws.send({type:'claim',requestId:'c',boardId:'B0'});assert.equal((await ws.waitFor(m=>m.type==='claim-result'&&m.requestId==='c')).ok,true);
const solved={paths:p.solution.map(q=>({startGate:q.startGate,endGate:q.endGate,cells:q.cells})),solved:true,scoreAwarded:Number.MAX_SAFE_INTEGER,store:{pathIndex:0,cellIndex:0,itemIds:['level-min-10'],priceCoefficient:0,purchases:[]}};
r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solved}]})});assert.equal(r.response.status,200);const pulled=await req('/api/cloud/pull?since=0',{headers:auth(alice)}),state=pulled.body.page.states.B0;assert(state.scoreAwarded>0&&state.scoreAwarded<100000,'server must replace forged reward');assert(state.store&&state.store.priceCoefficient>=.8&&state.store.priceCoefficient<=1.2,'server must replace forged store pricing');assert.equal(state.store.itemIds.length,13);
r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solved}]})});assert.equal(r.response.status,200);const pulled=await req('/api/cloud/pull?since=0',{headers:auth(alice)}),state=pulled.body.page.states.B0;assert(state.scoreAwarded>0&&state.scoreAwarded<100000,'server must replace forged reward');assert(state.store&&state.store.priceCoefficient>=.8&&state.store.priceCoefficient<=1.2,'server must replace forged store pricing');assert.equal(state.store.itemIds.length,12);
const econ=(await req('/api/player/state',{headers:auth(alice)})).body.player;assert.equal(econ.earnedScore,state.scoreAwarded);assert.equal(econ.availableScore,state.scoreAwarded);
const forged=meta('B1',999999,123,p);r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forged],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.notEqual(r.response.status,200,'non-adjacent forged board must be rejected');
const forgedDifficulty=meta('B1',1,123,p);forgedDifficulty.level=10;r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forgedDifficulty],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,400,'client-authored difficulty must be rejected in favor of server-derived puzzle facts');
console.log('Server authority security smoke test passed');
})().catch(e=>{console.error(e);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{ws?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});

View file

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

View file

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

View file

@ -0,0 +1,42 @@
'use strict';
const assert=require('assert/strict');
const fs=require('fs');
const os=require('os');
const path=require('path');
const dataRoot=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-recovery-')),dataDir=path.join(dataRoot,'world');
process.env.LINK_FIELD_TEST_DATA_ROOT=dataRoot;
const BuildMeta=require('../build-meta');
const server=require('../server');
const worldFile=path.join(dataDir,'shared-world.json'),commitFile=path.join(dataDir,'shared-world.commit.json'),boardsDir=path.join(dataDir,'shared-world.boards');
const playerId='aaaaaaaaaaaaaaaaaaaaaaaa',playerFile=path.join(dataDir,`${playerId}.json`);
const player=earnedScore=>({playerId,name:'Player',tokenHash:'0'.repeat(64),purchases:[],generationBonuses:[],earnedScore,economyRevision:earnedScore,createdAt:1,updatedAt:1});
const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[],clearEvents:[],expansionGrants:{},global:{nextId:1,worldGeneration:BuildMeta.WORLD_GENERATION},createdAt:1,updatedAt:1});
(async()=>{
fs.mkdirSync(boardsDir,{recursive:true});
fs.writeFileSync(worldFile,JSON.stringify(world(0)));
fs.writeFileSync(playerFile,JSON.stringify(player(0)));
fs.writeFileSync(path.join(boardsDir,'B0.1.json'),JSON.stringify({meta:{id:'B0'},state:{solved:true}}));
const nextWorld=world(1);nextWorld.boardVersions.B0=1;
fs.writeFileSync(commitFile,JSON.stringify({revision:1,world:nextWorld,nextPlayer:player(100),previousPlayer:player(0),changedBoardIds:['B0']}));
assert.equal(await server.recoverPendingWorldCommit(),true);
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
assert.equal(fs.existsSync(commitFile),false);
fs.writeFileSync(path.join(boardsDir,'B0.0.json'),'{}');
assert.equal(await server.collectRetiredBoardVersions(),1);
assert.equal(fs.existsSync(path.join(boardsDir,'B0.1.json')),true);
assert.equal(fs.existsSync(path.join(boardsDir,'B0.0.json')),false);
const failedWorld=world(2);failedWorld.boardVersions={B0:1,B1:2};
fs.writeFileSync(playerFile,JSON.stringify(player(200)));
fs.writeFileSync(commitFile,JSON.stringify({revision:2,world:failedWorld,nextPlayer:player(200),previousPlayer:player(100),changedBoardIds:['B1']}));
assert.equal(await server.recoverPendingWorldCommit(),false);
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
assert.equal(fs.existsSync(commitFile),false);
const poisoned=JSON.parse(fs.readFileSync(worldFile,'utf8'));poisoned.global.worldGeneration='foreign-generation';fs.writeFileSync(worldFile,JSON.stringify(poisoned));fs.writeFileSync(path.join(boardsDir,'B9.99.json'),'{}');assert.equal(await server.collectRetiredBoardVersions(),0);assert.equal(fs.existsSync(path.join(boardsDir,'B9.99.json')),true,'Generation mismatch cleanup deleted an unreferenced shard');
console.log('Shared-world commit recovery and retired-version collection passed');
})().finally(()=>fs.rmSync(dataRoot,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});

View file

@ -8,9 +8,10 @@ const {root,starterPuzzle}=require('./helpers/app-source');
const {connectRealtime}=require('./helpers/realtime-client');
const port=19000+Math.floor(Math.random()*10000);
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-shared-world-'));
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-shared-world-'));
const worldDir=path.join(dataDir,'world');
const child=spawn(process.execPath,[path.join(root,'server.js')],{
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']
});
let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
const base=`http://127.0.0.1:${port}`;
@ -27,7 +28,11 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
(async()=>{
for(let i=0;i<60;i++){try{const {response}=await request('/api/cloud/status');if(response.ok)break}catch(_){}await sleep(50)}
const status=await request('/api/cloud/status');assert.equal(status.response.status,200);assert.equal(status.body.sharedWorld,true);
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('app-logic.js?v=47.77')>page.body.indexOf('puzzle-core.js?v=47.77-5')&&page.body.indexOf('app-logic.js?v=47.77')<page.body.indexOf('app.js?v=47.77'));
const mountedStatus=await request('/~333/link-field/api/cloud/status');assert.equal(mountedStatus.response.status,200);assert.equal(mountedStatus.body.sharedWorld,true);
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('build-meta.js')<page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')>page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')<page.body.indexOf('app.js'));
const mountedPage=await requestText('/~333/link-field/');assert.equal(mountedPage.response.status,200);assert.match(mountedPage.body,/LinkField/);
const runtimeConfig=await requestText('/runtime-config.js');assert.equal(runtimeConfig.response.status,200);assert.match(runtimeConfig.body,/cloudApi:true/);
const mountedRuntimeConfig=await requestText('/~333/link-field/runtime-config.js');assert.equal(mountedRuntimeConfig.response.status,200);assert.match(mountedRuntimeConfig.body,/cloudApi:true/);
const logicAsset=await requestText('/app-logic.js');assert.equal(logicAsset.response.status,200);assert.match(logicAsset.body,/BendAppLogic/);
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
@ -36,12 +41,12 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
assert.equal(alice.name,'Alice');assert.equal(bob.name,'Bob');
const starter=starterPuzzle(),b0=boardMeta('B0',0,123456,starter);
const bootstrap={baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1,score:999,cursorStyle:'do-not-share',cloudProfile:{token:'do-not-store'}},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]};
const bootstrap={baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'linkfield-single-world-20260801',nextId:1,score:999,cursorStyle:'do-not-share',cloudProfile:{token:'do-not-store'}},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]};
const pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(bootstrap)});assert.equal(pushed.response.status,200);assert.equal(pushed.body.revision,1);assert.deepEqual(pushed.body.clearEvents,[]);
const bobInitial=await request('/api/cloud/pull?since=0&eventsSince=0',{headers:auth(bob)});assert.equal(bobInitial.response.status,200);assert.equal(bobInitial.body.changed,true);assert.equal(bobInitial.body.fullSnapshot,true);assert.equal(bobInitial.body.page.metas.B0.seed,123456);assert.equal(bobInitial.body.page.states.B0.solved,false);assert(bobInitial.body.page.metas.B0.rev>1_000_000_000_000);assert(bobInitial.body.page.states.B0.rev>1_000_000_000_000);assert.equal(bobInitial.body.page.global.score,undefined);assert.equal(bobInitial.body.page.global.cursorStyle,undefined);assert.equal(bobInitial.body.page.global.cloudProfile,null);assert.equal(bobInitial.body.player.name,'Bob');
aliceRealtime=await connectRealtime(base,alice);aliceRealtime.send({type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10});await aliceRealtime.waitFor('snapshot');aliceRealtime.send({type:'claim',requestId:'server-smoke-claim',boardId:'B0'});const claimResult=await aliceRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='server-smoke-claim');assert.equal(claimResult.ok,true);
aliceRealtime=await connectRealtime(base+'/~333/link-field',alice);aliceRealtime.send({type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10});await aliceRealtime.waitFor('snapshot');aliceRealtime.send({type:'claim',requestId:'server-smoke-claim',boardId:'B0'});const claimResult=await aliceRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='server-smoke-claim');assert.equal(claimResult.ok,true);
const clearPayload={baseRevision:1,global:{nextId:1,lastSolveAt:Date.now()},metas:[],states:[{id:'B0',value:solvedState(starter)}],deleted:[]};
const cleared=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(clearPayload)});assert.equal(cleared.response.status,200);assert.equal(cleared.body.revision,2);assert.equal(cleared.body.clearEvents.length,1);assert.equal(cleared.body.clearEvents[0].playerName,'Alice');assert.equal(cleared.body.clearEvents[0].id,'B0');assert.equal(cleared.body.clearEvents[0].level,1);
@ -62,8 +67,8 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
const overlap=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:4,global:{},metas:[boardMeta('B2',1,222,starter)],states:[],deleted:[]})});assert.equal(overlap.response.status,400);
const aliceRecord=JSON.parse(fs.readFileSync(path.join(dataDir,`${alice.playerId}.json`),'utf8'));assert.equal(aliceRecord.name,'Alice');assert.notEqual(aliceRecord.tokenHash,alice.token);assert.equal(aliceRecord.boardVersions,undefined);assert.equal(aliceRecord.global,undefined);
const world=JSON.parse(fs.readFileSync(path.join(dataDir,'shared-world.json'),'utf8'));assert.equal(world.revision,4);assert.equal(world.global.score,undefined);assert.equal(world.global.cursorStyle,undefined);assert.equal(world.boardVersions.B0,3);assert.equal(world.boardVersions.B1,4);assert.equal(world.clearEvents[0].playerName,'Alice');
const b0Shard=JSON.parse(fs.readFileSync(path.join(dataDir,'shared-world.boards','B0.3.json'),'utf8'));assert.equal(b0Shard.state.solvedBy,'Alice');assert.equal(b0Shard.state.paths.length,starter.solution.length);
const aliceRecord=JSON.parse(fs.readFileSync(path.join(worldDir,`${alice.playerId}.json`),'utf8'));assert.equal(aliceRecord.name,'Alice');assert.notEqual(aliceRecord.tokenHash,alice.token);assert.equal(aliceRecord.boardVersions,undefined);assert.equal(aliceRecord.global,undefined);
const world=JSON.parse(fs.readFileSync(path.join(worldDir,'shared-world.json'),'utf8'));assert.equal(world.revision,4);assert.equal(world.global.score,undefined);assert.equal(world.global.cursorStyle,undefined);assert.equal(world.boardVersions.B0,3);assert.equal(world.boardVersions.B1,4);assert.equal(world.clearEvents[0].playerName,'Alice');
const b0Shard=JSON.parse(fs.readFileSync(path.join(worldDir,'shared-world.boards','B0.3.json'),'utf8'));assert.equal(b0Shard.state.solvedBy,'Alice');assert.equal(b0Shard.state.paths.length,starter.solution.length);
console.log('BEND FIELD shared-world phase 2 server smoke test passed');
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceRealtime?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});

View file

@ -0,0 +1,63 @@
'use strict';
const assert=require('assert/strict');
const vm=require('vm');
const {read,functionSource,buildMeta}=require('./helpers/app-source');
const SharedContracts=require('../shared-contracts');
const ServerContracts=require('../server');
const packageVersion=JSON.parse(read('package.json')).version;
assert.equal(buildMeta.PACKAGE_VERSION,packageVersion);
assert.deepEqual(
SharedContracts.normalizeSpecialMechanics(['internalGate','warp','crossing','lock','warp','invalid']),
['crossing','internalGate','lock','warp']
);
assert.deepEqual(
ServerContracts.sanitizeWorldGlobal({specialMechanicsSeen:['internalGate','warp','invalid']},{global:{}}).specialMechanicsSeen,
['internalGate','warp']
);
const catalog=JSON.parse(read('store-catalog.json'));
const catalogMap=new Map(catalog.map(item=>[item.id,item]));
const generatedExpected=`'use strict';\n// Generated from store-catalog.json by scripts/generate-store-catalog.js. Do not edit.\nglobalThis.BendStoreCatalog=Object.freeze(${JSON.stringify(catalog)}.map(item=>Object.freeze(item)));\n`;
assert.equal(read('store-catalog.generated.js'),generatedExpected,'Browser store catalog is stale; run the catalog generator');
const generatedContext={};vm.createContext(generatedContext);vm.runInContext(generatedExpected,generatedContext);
assert.deepEqual(JSON.parse(JSON.stringify(generatedContext.BendStoreCatalog)),catalog);
const fixtures=[
{purchaseId:'purchase:1',boardId:'B0',itemId:'score-lens',buyer:' Alice Example ',boughtAt:100,paidCost:200000},
{purchaseId:'purchase:1',boardId:'B1',itemId:'score-lens',buyer:'duplicate-id',boughtAt:101,paidCost:200000},
{purchaseId:'purchase:2',boardId:'B0',itemId:'score-lens',buyer:'duplicate-item',boughtAt:102,paidCost:200000},
{purchaseId:'purchase:3',boardId:'B01',itemId:'score-lens',buyer:'bad-board',boughtAt:103,paidCost:200000},
{purchaseId:'purchase:4',boardId:'B2',itemId:'cursor-face-1f600',buyer:'123456789012345678901234567890',boughtAt:104,paidCost:Number.MAX_SAFE_INTEGER},
{purchaseId:'bad purchase',boardId:'B3',itemId:'score-lens',buyer:'bad-id',boughtAt:105,paidCost:200000}
];
const clientContext={
SharedContracts,
storeItem:id=>catalogMap.get(String(id||''))||null,
MAX_SCORE:Number.MAX_SAFE_INTEGER,
DEFAULT_PLAYER_NAME:'旅人'
};
vm.createContext(clientContext);
vm.runInContext(`${functionSource('normalizePlayerPurchases')}\nthis.normalizePlayerPurchases=normalizePlayerPurchases;`,clientContext);
const client=JSON.parse(JSON.stringify(clientContext.normalizePlayerPurchases(fixtures)));
const server=ServerContracts.normalizePlayerPurchases(fixtures);
assert.deepEqual(client,server,'Client and server purchase normalization diverged');
assert.equal(client.length,2);
assert.equal(client[0].buyer,'Alice Example');
assert.equal(client[1].buyer.length,24);
assert.equal(client[1].paidCost,Number.MAX_SAFE_INTEGER);
const runtimeContext={globalThis:null};runtimeContext.globalThis=runtimeContext;vm.createContext(runtimeContext);vm.runInContext(read('runtime-config.js'),runtimeContext);
assert.equal(runtimeContext.BendRuntimeConfig.cloudApi,true,'LinkField no longer supports a local-only runtime');
assert.equal(runtimeContext.BendRuntimeConfig.singleSharedWorld,true,'Runtime must require the single shared world');
const hostedRuntimeContext={globalThis:null,URL,location:{protocol:'https:',href:'https://host.example/~333/link-field/'},document:{currentScript:{src:'https://host.example/~333/link-field/runtime-config.js'}}};hostedRuntimeContext.globalThis=hostedRuntimeContext;vm.createContext(hostedRuntimeContext);vm.runInContext(read('runtime-config.js'),hostedRuntimeContext);
assert.equal(hostedRuntimeContext.BendRuntimeConfig.cloudApi,true,'HTTP hosting must enable the shared-world API');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.appBaseUrl,'https://host.example/~333/link-field/','Hosted runtime did not preserve the application mount path');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.apiBridgeUrl,'https://host.example/~333/link-field/api-bridge.php','Hosted runtime did not configure the PHP API bridge');
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'auto','Hosted runtime must prefer WebSocket and fall back to bounded HTTP polling');
const endpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api/'};vm.createContext(endpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}
this.cloudEndpointUrl=cloudEndpointUrl;`,endpointContext);
assert.equal(endpointContext.cloudEndpointUrl('/api/cloud/status'),'https://host.example/~333/link-field/api/cloud/status','Cloud API URL lost the mounted application path');
const bridgeEndpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api-bridge.php',cloudApiBridgeUrl:'https://host.example/~333/link-field/api-bridge.php'};vm.createContext(bridgeEndpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}\nthis.cloudEndpointUrl=cloudEndpointUrl;`,bridgeEndpointContext);
assert.equal(bridgeEndpointContext.cloudEndpointUrl('/api/cloud/pull?since=7'),'https://host.example/~333/link-field/api-bridge.php?path=%2Fapi%2Fcloud%2Fpull&since=7','PHP bridge URL did not preserve the API path and query');
console.log('Canonical build, store, mechanics, runtime mode, and purchase contracts passed');

View file

@ -2,6 +2,7 @@
const assert=require('assert/strict');
const vm=require('vm');
const {functionSource,loadAppLogic}=require('./helpers/app-source');
const {normalizeSpecialMechanics}=require('../shared-contracts');
const outboxContext={
data:{states:{B0:{solved:false},B1:{solved:true}}},cloudApiEnabled:true,
@ -10,11 +11,11 @@ const outboxContext={
vm.createContext(outboxContext);
vm.runInContext(`${functionSource('currentCloudPending')}\n${functionSource('noteCloudRow')}\nthis.logic={currentCloudPending,noteCloudRow};`,outboxContext);
outboxContext.logic.noteCloudRow('state','B0');
assert.equal(outboxContext.cloudJournalStateIds.has('B0'),false,'Unsolved personal path entered the shared journal');
assert.equal(outboxContext.cloudOutboxDeleteKeys.has('state:B0'),true,'Stale unsolved shared outbox row was not scheduled for deletion');
assert.equal(outboxContext.cloudJournalStateIds.has('B0'),true,'Unfinished shared path was not added to the shared journal');
assert.equal(outboxContext.cloudOutboxDeleteKeys.has('state:B0'),false,'Unfinished shared path was incorrectly deleted from the outbox');
outboxContext.logic.noteCloudRow('state','B1');
assert.equal(outboxContext.cloudJournalStateIds.has('B1'),true,'Solved state was not added to the shared journal');
assert.deepEqual([...outboxContext.logic.currentCloudPending().stateIds],['B1']);
assert.deepEqual([...outboxContext.logic.currentCloudPending().stateIds],['B0','B1']);
const AppLogic=loadAppLogic(),now=1_800_000_000_000;
const leaseContext={
@ -25,48 +26,63 @@ vm.createContext(leaseContext);
vm.runInContext(`${functionSource('sharedExpansionRepairDelay')}\nthis.sharedExpansionRepairDelay=sharedExpansionRepairDelay;`,leaseContext);
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'bbbbbbbbbbbbbbbb',solvedAt:now}),0,'The solving client cannot expand its own clear');
const wait=leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now});
assert(wait>=60_000&&wait<90_000,'A non-solving client can race the solver before the recovery grace period');
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now-100_000}),0,'A disconnected solver can leave expansion permanently blocked');
leaseContext.cloudAvailable=false;
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now}),0,'Offline expansion was incorrectly lease-gated');
assert.equal(wait,Infinity,'A non-solving client can generate transient boards that the server will reject');
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now-100_000}),Infinity,'Expansion authority silently transfers away from the solver');
const localMeta={id:'B0',x:99,y:99,seed:1,chunks:[[0,0]],sealedSides:[],rev:9_999};
const remoteMeta={id:'B0',x:0,y:0,seed:2,chunks:[[0,0]],sealedSides:[],rev:2_000};
const authoritativeContext={
cloudApiEnabled:true,
data:{
metas:{B0:localMeta},states:{B0:{solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999}},
nextId:99,solved:1,lastSolveAt:123,specialMechanicsSeen:['warp'],quarantine:{local:true},
playerName:'Player',score:777,cursorStyle:'flag-jp'
},
isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),lastRevision:0,normalizedStateObjects:new Set(),
cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),
normalizeSpecialMechanics,
cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),cloudJournalChangeSeq:0,
sameMetaGeometry:(a,b)=>a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[]),
destroyBoard:()=>{},destroyStaticBoard:()=>{},rendered:new Map(),staticRendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),
destroyBoard:()=>{},rendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),sameDataValue:(a,b)=>JSON.stringify(a)===JSON.stringify(b),
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0),
mergeBoardStates:(current,incoming)=>{
if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(incoming));
if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(current));
if(current&&!current.solved&&!incoming.solved)return{...JSON.parse(JSON.stringify(incoming)),paths:JSON.parse(JSON.stringify(current.paths||[]))};
return JSON.parse(JSON.stringify(incoming));
},
markStateDirty:id=>authoritativeContext.dirtyStateIds.add(id),dirtyStateIds:new Set(),sanitizeStateForPuzzle:()=>{},
markStateDirty:id=>authoritativeContext.dirtyStateIds.add(id),dirtyStateIds:new Set(),sanitizeStateForPuzzle:()=>{},boardClaimOwnedByMe:()=>false,
mergeGlobalFields:()=>{throw new Error('Authoritative shared global unexpectedly used generic merge')},resolveMergedOverlaps:()=>[],statsDirty:false
};
vm.createContext(authoritativeContext);
vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext);
vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('noteCloudRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext);
authoritativeContext.mergeSnapshotIntoData({
metas:{B0:remoteMeta},states:{B0:{solved:false,paths:[],rev:2_000}},
nextId:2,solved:0,lastSolveAt:0,specialMechanicsSeen:['lock'],quarantine:{shared:true}
},{finalize:false,authoritativeWorld:true});
assert.equal(authoritativeContext.data.metas.B0.seed,2,'An existing local world row overrode the authoritative shared board');
assert.equal(authoritativeContext.data.states.B0.solved,false,'An unconfirmed local clear survived authoritative shared adoption');
assert.equal(authoritativeContext.data.states.B0.solved,false,'A clear from a replaced board definition leaked into the authoritative shared board');
assert.equal(authoritativeContext.data.nextId,2,'A private local board counter leaked into the shared world');
assert.equal(authoritativeContext.data.playerName,'Player');assert.equal(authoritativeContext.data.score,777);assert.equal(authoritativeContext.data.cursorStyle,'flag-jp');
assert.equal(authoritativeContext.cloudJournalMetaIds.size,0);assert.equal(authoritativeContext.cloudJournalStateIds.size,0);
assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Adopted shared rows did not clear stale local outbox records');
assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Replaced shared rows did not clear stale local outbox records');
// Matching unsolved boards keep the player's unfinished line locally while the board definition stays shared.
// A durable clear for the same board definition survives a stale authoritative snapshot and is queued for upload.
authoritativeContext.cloudJournalStateIds.clear();authoritativeContext.cloudOutboxDeleteKeys.clear();
authoritativeContext.data.metas.B0=JSON.parse(JSON.stringify(remoteMeta));
authoritativeContext.data.states.B0={solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999};
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:4_000}},states:{B0:{solved:false,paths:[],rev:4_000}},nextId:2},{finalize:false,authoritativeWorld:true});
assert.equal(authoritativeContext.data.states.B0.solved,true,'A durable clear was downgraded by a stale authoritative snapshot of the same board');
assert.deepEqual([...authoritativeContext.cloudJournalStateIds],['B0'],'The retained clear was not queued for shared upload');
assert.equal(authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),false,'The retained clear was incorrectly scheduled for deletion');
// Matching unsolved boards adopt the authoritative shared progress when this player has no pending claim.
authoritativeContext.data.metas.B0=remoteMeta;
authoritativeContext.data.states.B0={solved:false,paths:[{startGate:0,cells:[[0,0],[0,1]]}],rev:3_000};
authoritativeContext.cloudJournalStateIds.clear();
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:4_000}},states:{B0:{solved:false,paths:[],rev:4_000}},nextId:2},{finalize:false,authoritativeWorld:true});
assert.equal(authoritativeContext.data.states.B0.paths.length,1,'Authoritative shared refresh erased a matching board\'s personal unfinished path');
assert.equal(authoritativeContext.data.states.B0.paths.length,0,'Authoritative shared progress did not replace stale local unfinished progress');
// The active claimant keeps an unsent local update during a revision-conflict pull, then retries it.
authoritativeContext.data.states.B0={solved:false,paths:[{startGate:0,cells:[[0,0],[0,1]]}],rev:5_000};
authoritativeContext.cloudJournalStateIds.add('B0');authoritativeContext.boardClaimOwnedByMe=()=>true;
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:6_000}},states:{B0:{solved:false,paths:[],rev:6_000}},nextId:2},{finalize:false,authoritativeWorld:true});
assert.equal(authoritativeContext.data.states.B0.paths.length,1,'The active claimant lost an unsent path during conflict recovery');
console.log('Shared-world phase 1 client synchronization smoke test passed');

View file

@ -11,12 +11,12 @@ const app=read('app.js'),html=read('index.html'),css=read('style.css'),serverSou
assert(html.includes('id="reactionCanvas"')&&html.includes('id="reactionRadial"')&&css.includes('#reactionCanvas')&&css.includes('z-index:2'),'Reaction layer is not behind boards');
assert(functionSource('beginReactionGesture').includes('REACTION_LONG_PRESS_MS')&&functionSource('reactionAllowedAt').includes('metaState(boardId).solved'),'Reaction click/long-press eligibility is missing');
assert(functionSource('drawReactionLayer').includes('REALTIME_REACTION_DURATION')||app.includes('REALTIME_REACTION_DURATION=4500'),'Reaction animation is not bounded');
assert(functionSource('inventoryEntries').includes('onlinePlayerEconomy()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed');
assert(serverSource.includes("url.pathname==='/api/player/purchase'")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("url.pathname==='/api/player/place-field'"),'Player purchase validation is missing or retired field-placement API remains');
assert(functionSource('inventoryEntries').includes('personalEconomyMode()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed');
assert(serverSource.includes(".add('POST','/api/player/purchase',handlePurchase)")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("'/api/player/place-field'"),'Player purchase validation is missing or retired field-placement API remains');
assert(realtimeSource.includes("message.type === 'reaction'")&&realtimeSource.includes('REACTION_MIN_INTERVAL_MS')&&!realtimeSource.includes('broadcastFieldEffect'),'Realtime reaction throttling is missing or field broadcasts remain');
const port=24000+Math.floor(Math.random()*8000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-complete-'));
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']});
const port=24000+Math.floor(Math.random()*8000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-complete-')),worldDir=path.join(dataDir,'world');
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']});
let stderr='',aliceWs=null,bobWs=null;child.stderr.on('data',chunk=>stderr+=chunk);
const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function request(url,options={}){const response=await fetch(base+url,options),body=await response.json();return{response,body}}
@ -29,14 +29,20 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
const status=(await request('/api/cloud/status')).body;assert.equal(status.reactions,true);assert.equal(status.playerEconomy,true);assert.equal(status.sharedItems,false);
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,15,puzzle);
let pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});assert.equal(pushed.response.status,200);
const puzzle=starterPuzzle(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];
puzzle.g=[[0,1,'N'],[4,2,'S']];puzzle.n=[[0,0,16]];puzzle.valid=route.map(cell=>[...cell]);puzzle.obstacles=[[2,2]];puzzle.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];puzzle.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};puzzle.maxTurns=16;puzzle.totalTurns=16;
const b0=boardMeta('B0',0,15,puzzle);
let pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'linkfield-single-world-20260801',nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});assert.equal(pushed.response.status,200);
aliceWs=await connectRealtime(base,alice);bobWs=await connectRealtime(base,bob);aliceWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});bobWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});await aliceWs.waitFor('snapshot');await bobWs.waitFor('snapshot');
aliceWs.send({type:'reaction',id:'reaction-one',emoji:'🎉',x:.4,y:.6});const reaction=await bobWs.waitFor(message=>message.type==='reaction'&&message.reaction?.id==='reaction-one');assert.equal(reaction.reaction.emoji,'🎉');assert.equal(reaction.reaction.playerName,'Alice');assert(reaction.reaction.expiresAt>reaction.reaction.createdAt);
aliceWs.send({type:'reaction',id:'reaction-one',emoji:'🤩',style:'laser',x:.4,y:.6});const reaction=await bobWs.waitFor(message=>message.type==='reaction'&&message.reaction?.id==='reaction-one');assert.equal(reaction.reaction.emoji,'🤩');assert.equal(reaction.reaction.style,'laser');assert.equal(reaction.reaction.playerName,'Alice');assert(reaction.reaction.expiresAt>reaction.reaction.createdAt);
aliceWs.send({type:'claim',requestId:'claim-b0',boardId:'B0'});assert.equal((await aliceWs.waitFor(message=>message.type==='claim-result'&&message.requestId==='claim-b0')).ok,true);
pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}],deleted:[]})});assert.equal(pushed.response.status,200);let revision=pushed.body.revision;
const alicePath=path.join(dataDir,`${alice.playerId}.json`),aliceRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));aliceRecord.earnedScore=250000;fs.writeFileSync(alicePath,JSON.stringify(aliceRecord));
const purchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens');
const alicePath=path.join(worldDir,`${alice.playerId}.json`),aliceRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));aliceRecord.earnedScore=250000;fs.writeFileSync(alicePath,JSON.stringify(aliceRecord));
const [renamed,purchase]=await Promise.all([
request('/api/cloud/profile',{method:'POST',headers:auth(alice),body:JSON.stringify({name:'Alice Concurrent'})}),
request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})})
]);assert.equal(renamed.response.status,200);assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens');
const racedRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));assert.equal(racedRecord.name,'Alice Concurrent');assert.equal(racedRecord.purchases.length,1,'Concurrent profile update overwrote the purchase');
const duplicate=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.purchase.purchaseId,purchase.body.purchase.purchaseId);
const bobState=await request('/api/player/state',{headers:auth(bob)});assert.deepEqual(bobState.body.player.purchases,[]);
const retiredPurchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10'})});assert.equal(retiredPurchase.response.status,400);

View file

@ -1,21 +1,23 @@
'use strict';
const cp=require('child_process');
const fs=require('fs');
const {root,path,vm,app,html,css,worker,appLogicSource,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
const {root,path,vm,app,html,css,worker,appLogicSource,buildMeta,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
const serverSource=read('server.js');
const packageVersion=JSON.parse(read('package.json')).version,appVersion=packageVersion.split('.').slice(0,2).join('.');
const storeCatalog=JSON.parse(read('store-catalog.json'));
for(const file of ['app.js','app-logic.js','puzzle-core.js','puzzle-worker.js','field-persistence.js','field-persistence-worker.js','server.js'])cp.execFileSync(process.execPath,['--check',path.join(root,file)],{stdio:'inherit'});
assert(buildMeta.APP_VERSION===appVersion&&buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801','Canonical build metadata does not match the package or persistence contracts');
for(const marker of [
`APP_VERSION='${appVersion}',SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'`,
'SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false',
'SPECIAL_CELL_MIN_LEVEL=5',
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
'specialPathValid','crossingsSatisfied','activateCrossing','pathRenderSegments','pathStrokePieces','pathProgressAtCell','specialCellInfoMap','normalizeSpecialCells'
])assert(app.includes(marker),`Missing v47 marker: ${marker}`);
assert(!app.includes('SPECIAL_CELL_DEBUG_ALL_LEVELS'),'Special-cell generation still contains a debug-all-levels switch');
assert(app.includes('const AppLogic=globalThis.BendAppLogic')&&app.includes('AppLogic.shapeCandidatesForLevel')&&app.includes('AppLogic.stateForStorage')&&app.includes('AppLogic.collectConnectedLineComponent'),'Application does not consume the shared pure-logic module');
assert(html.includes(`style.css?v=${appVersion}`)&&html.includes(`puzzle-core.js?v=${appVersion}-5`)&&html.includes(`app-logic.js?v=${appVersion}`)&&html.includes(`field-persistence.js?v=${appVersion}`)&&html.includes(`app.js?v=${appVersion}`),'Web assets do not match package/app/generator version');
assert(worker.includes(`puzzle-core.js?v=${appVersion}-5`),'Worker imports an old puzzle-core asset');
assert(html.includes('build-meta.js')&&html.indexOf('build-meta.js')<html.indexOf('puzzle-core.js')&&html.indexOf('shared-contracts.js')<html.indexOf('app.js'),'Canonical metadata/contracts are not loaded before application assets');
assert(worker.includes('build-meta.js')&&worker.includes('BendBuildMeta.APP_VERSION')&&worker.includes('BendBuildMeta.GENERATOR_VERSION'),'Worker does not derive its puzzle-core asset version from canonical metadata');
assert(functionSource('createPuzzleWorker').includes('message.error?job.reject')&&!functionSource('createPuzzleWorker').includes('message.error?generatePuzzleOnMainThread'),'Algorithmic worker failures are retried redundantly on the main thread');
assert(html.includes(`<small>v${appVersion}</small>`)&&html.includes(`v${appVersion}</title>`),'Visible version does not match package/app version');
assert(app.includes('const appVersionLabel=`v${APP_VERSION}`')&&app.includes('brandVersion.textContent=appVersionLabel')&&app.includes('document.title=`${document.title.replace('),'Visible version is not derived from canonical build metadata');
assert(html.includes('id="viewport" tabindex="-1"')&&['modal','storeModal','inventoryModal','timeAttackModal'].every(id=>html.includes(`id="${id}" aria-hidden="true" inert`)),'Hidden dialogs are not inert or the viewport is not programmatically focusable');
assert(functionSource('closeDialogRoot').includes('focusOutsideDialog(root,preferredFocus)')&&functionSource('closeDialogRoot').indexOf('focusOutsideDialog')<functionSource('closeDialogRoot').indexOf("setAttribute('aria-hidden','true')"),'Dialog hiding occurs before focus leaves the dialog');
assert(functionSource('openDialogRoot').includes('setDialogInert(root,false)')&&functionSource('closeDialogRoot').includes('setDialogInert(root,true)'),'Dialog inert state is not synchronized with visibility');
@ -23,11 +25,11 @@ assert(!app.includes('labyrinth-seed')&&!app.includes('giantCompactShapes')&&!ap
assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app.includes('renderAnomalyOverlays')&&!css.includes('.anomaly'),'Retired anomaly code remains');
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes("url.pathname==='/api/cloud/profile'")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending(b.id)')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
assert(functionSource('noteCloudRow').includes("kind==='state'&&data?.states?.[id]?.solved!==true")&&functionSource('currentCloudPending').includes("solved===true"),'Unfinished personal paths can still enter the shared durable outbox');
assert(functionSource('sharedExpansionRepairDelay').includes('SHARED_EXPANSION_GRACE_MS')&&functionSource('repairExpansions').includes('sharedExpansionRepairDelay(st)<=0'),'Non-solving clients can race the solver while publishing newly generated boards');
assert(!functionSource('noteCloudRow').includes("solved!==true")&&functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished shared paths are still excluded from the durable outbox');
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()')&&functionSource('repairExpansions').includes('canExpandSharedBoard(meta,st)'),'Non-solving clients can race the solver while publishing newly generated boards');
assert(app.includes('solvedById')&&appLogicSource.includes('solvedById')&&serverSource.includes('state.solvedById=player.playerId'),'Shared solver identity is not persisted independently of the display name');
assert(serverSource.includes('rowRevision=Math.max')&&serverSource.includes('serverTime()*1000'),'Server row revisions are not comparable with client revisions');
assert(!app.includes('nearestUnselectedEndpointAtClient'),'Unselected endpoint clicks are still intercepted before dragging');
@ -42,10 +44,10 @@ assert(functionSource('makeSpecialMarker').includes("class:'key-ring'")&&!functi
assert(css.includes('.board-card.solved .special-cell-layer'),'Solved boards do not hide special cells');
assert(functionSource('selectBoard').includes('setActiveBoard(b.id)')&&functionSource('setActiveBoard').includes('previous.drawing?.pointerId==null'),'Board selection does not preserve an active pointer draw while reconciling inactive keyboard state');
assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remains active');
assert(css.includes('.board-card.hud-current:not(.solved) .board-label')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('renderBoardNow').includes("card.classList.toggle('hud-current',hudVisible)"),'Board HUD is not limited to an actively played board');
assert(html.includes('id="boardHudLayer"')&&css.includes('.board-label.hud-visible:not([hidden])')&&css.includes('.board-label[hidden]{display:none!important}')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&functionSource('refreshInteractionState').includes('setBoardHudVisibility(board,boardPlayHudVisible(board))')&&functionSource('renderBoardNow').includes('setBoardHudVisibility(b,hudVisible)'),'Board HUD is not retained for the last manipulated board or is still clipped inside the board');
assert(functionSource('gateFromCell').includes('maxPixels')&&functionSource('extendPointerTo').includes('active.startGate,20'),'Opposite gate selection is not distance-limited');
assert(functionSource('renderBoardNow').includes('pathStrokePieces(segments,startColor,endColor)')&&functionSource('pathColorAtCell').includes('pathProgressAtCell'),'Line colors are not blended along cumulative route length');
assert(functionSource('updateSelectedProgress').includes('b.meta.level')&&!functionSource('updateSelectedProgress').includes('filled'),'Top HUD includes information other than level');
assert(functionSource('updateSelectedProgress')==='function updateSelectedProgress(){}'&&!html.includes('id="selectedInfo"'),'Top HUD still exposes the current board level');
assert(functionSource('renderBoardNow').includes('label.replaceChildren')&&functionSource('makeBoard').includes('label.append(boardActions)'),'Board HUD does not contain the level and attached actions');
assert(html.includes('id="noiseCanvas" width="80" height="64"')&&functionSource('paintNoiseBackground').includes("perfCount('noiseFrames')")&&!css.includes('starTwinkle'),'Low-resolution noise background is missing or the retired starfield remains');
assert(functionSource('unresolvedExpansionCandidates').includes('gateFrontierCandidates(meta)')&&!functionSource('unresolvedExpansionCandidates').includes('frontierCandidates(meta)'),'Normal expansion still creates non-gate frontier boards');
@ -53,22 +55,22 @@ assert(functionSource('syncBoundaryConnections').includes('boundaryColorSource(m
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldEffects')&&!app.includes('fieldOverlayCanvas')&&!serverSource.includes('/api/player/place-field'),'Difficulty adjustment items or their field implementation remain active');
assert(functionSource('makeBoard').includes('cellShape')&&functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('makeBoard').includes('cellHits'),'Detailed boards do not use compound SVG paths or still allocate per-cell hit nodes');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible puzzles are not all selected for detailed rendering');
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!app.includes('function promoteStaticBoard(')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'Lightweight FPS display or split interaction budgets are missing');
assert(!app.includes('shouldTeleportToUnsolvedBoard')&&!app.includes('makeStaticBoard')&&!app.includes('promoteStaticBoard')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'Lightweight FPS display or split interaction budgets are missing');
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px')&&functionSource('completionEffect').includes('1800'),'Completion gems are not enlarged or retained long enough');
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden'),'Non-flag custom cursors are still clipped or flag clipping is no longer isolated');
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('.board-input-surface')&&css.includes('body.is-drawing #viewport')&&functionSource('updateCustomCursorFromPointer').includes("classList.contains('is-drawing')"),'Custom cursor coverage or drag-time cursor hiding is incomplete');
assert(functionSource('extendOne').includes('warpedDuringExtend=true')&&!functionSource('extendOne').includes('safeRelease(b.svg,pointerId)'),'Warp traversal still releases pointer capture');
assert(functionSource('extendPointerTo').includes('pointerOffset'),'Warp continuation does not remap the pointer to the exit');
assert(!app.includes('hysteresisDragPoint')&&!app.includes('DRAG_AXIS_LOCK_DISTANCE')&&!app.includes('POINTER_SNAP_RELEASE'),'Retired drawing hysteresis remains active');
assert(functionSource('scheduleBoardDragFrame').includes('requestAnimationFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Pointer movement is not frame-batched with ordered samples');
assert(functionSource('queueCameraInteraction').includes('requestAnimationFrame')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
assert(functionSource('scheduleBoardDragFrame').includes('requestFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('scheduler.latest()')&&functionSource('processBoardDragFrame').includes('updatePickupHandleOverlay')&&!functionSource('processBoardDragFrame').includes('visualBlend')&&!css.includes('transition:transform 16.67ms linear'),'Pointer logic or pickup presentation is not confined to the capped frame lane');
assert(functionSource('queueCameraInteraction').includes('cameraInteractionScheduler.push')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('e.button!==2&&!leftFieldPanAllowed(e)'),'Left-drag field panning is not isolated to solved or undiscovered space');
assert(functionSource('processBoardDragFrame').includes('edgePanVelocity')&&functionSource('processBoardDragFrame').includes('applyCamera(true)'),'Drag edge auto-pan is missing');
assert(!functionSource('makeBoard').includes('darkness')&&!css.includes('.darkness'),'Retired darkness rendering remains');
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("placement.side==='S'"),'HUD does not move to a free edge');
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'")&&functionSource('positionBoardLabel').includes("b.label.style.top=(PAD-22)+'px'"),'Top/bottom HUD does not span the board edge or clear upper gates');
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side)'),'Gate hit areas are not constrained to the owning board');
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("side==='S'"),'HUD does not move to a free edge');
assert(functionSource('positionBoardLabel').includes('viewportRect.width-margin-labelWidth')&&functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)'),'Board HUD is not viewport-clamped in a detached overlay layer');
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side,g.internal)'),'Gate hit areas are not constrained to the owning board');
assert(!app.includes('sharedGateVisible'),'No-op shared-gate visibility wrapper remains');
assert(functionSource('isSolved').includes('crossingsSatisfied(st,p)'),'Crossing is not a prerequisite for normal board completion');
assert(functionSource('crossingsSatisfied').includes('crossingStateAtCell')&&functionSource('activateCrossing').includes('path.cells.push(cell)'),'Crossing is not derived from the live overlapping line state');
@ -78,9 +80,9 @@ assert(functionSource('resetSelectedBoard').includes('specialProgress={crossings
assert(functionSource('resetSelectedBoard').includes('renderBoardNow(b)')&&!functionSource('resetSelectedBoard').includes('syncBoundaryConnections'),'Reset is not immediate or still recreates inherited routes');
assert(!app.toLowerCase().includes('undolastreset')&&!html.toLowerCase().includes('undo'),'Visible reset undo remains active');
assert(app.includes('STORE_CHANCE=1/30'),'Store appearance rate is not 1/30');
assert(app.includes('STORE_CHANCE=1/10'),'Store appearance rate is not 1/10');
assert(app.includes('MINIMAP_VIEW_CHUNKS_X=42'),'Minimap does not use the wider scale');
assert(app.includes('SOUND_GAIN_MULTIPLIER=3.6')&&functionSource('soundTone').includes('Math.min(.28'),'Sound effects were not amplified');
assert(app.includes('SOUND_GAIN_MULTIPLIER=5.2')&&functionSource('soundTone').includes('Math.min(.42'),'Sound effects were not amplified');
assert(app.includes('UNIQUE_SOLUTION_MIN_LEVEL=6')&&functionSource('placeChildAtFrontierAttempt').includes('level>=UNIQUE_SOLUTION_MIN_LEVEL'),'Unique-solution selection does not begin at level 6');
assert(functionSource('shapeCandidatesForArea').includes('nearbyShapeFamilyCounts')&&appLogicSource.includes('generatedShapeFamilyKey')&&appLogicSource.includes('balancedShapeCandidates'),'Area-local board shape balancing is missing');
assert(!html.includes('&#x77E2;&#x5370;&#x30AD;&#x30FC;&#xFF1A;&#x7DDA;&#x3092;&#x4F38;&#x3070;&#x3059;')&&css.includes('.control-chips span{padding:9px 12px')&&css.includes('font-size:13px'),'Help controls are too small or still list arrow keys');
@ -89,31 +91,32 @@ assert(functionSource('addCrossingSpecial').includes('neighbors.some(candidate=>
assert(functionSource('updateZoomPresentation').includes('world-overview')&&functionSource('drawWorldOverview').includes('overviewCanvas')&&html.includes('id="overviewCanvas"')&&css.includes('#viewport.canvas-overview #world'),'Canvas overview mode is missing');
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not share the minimap renderer');
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{'),'Unsolved nearby boards do not preserve route thickness');
assert(!app.includes('makeStaticBoard')&&!css.includes('.static-summary'),'Inactive static-board LOD code remains');
assert(functionSource('updateTimeAttackUi').includes("classList.toggle('starting'")&&css.includes('@keyframes timeAttackStartEmphasis'),'Time-attack start clock emphasis is missing');
const yellowFaces=app.match(/const YELLOW_FACE_CURSOR_SOURCE=`([\s\S]*?)`;/)?.[1]?.split('\n')||[];
assert(yellowFaces.length===101&&yellowFaces.some(row=>row.startsWith('1FAE9|'))&&yellowFaces.some(row=>row.startsWith('1FAEA|')),'Complete Unicode Emoji 17.0 yellow-face cursor catalog is missing');
assert(app.includes('MAX_FACE_CURSOR_PRICE=50000')&&app.includes('cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Yellow-face cursor prices are not deterministic random values spanning 500-50000 gems');
assert(yellowFaces.length===97&&!yellowFaces.some(row=>/^(?:1FAE9|1FAEA|1F642 200D 219[45] FE0F)\|/.test(row)),'Glitchy or undisplayed yellow-face cursors remain');
const faceContracts=storeCatalog.filter(item=>item.id.startsWith('cursor-face-'));
assert(faceContracts.length===97&&Math.min(...faceContracts.map(item=>item.cost))===500&&Math.max(...faceContracts.map(item=>item.cost))===50000&&app.includes('MAX_FACE_CURSOR_PRICE=50000')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Canonical supported yellow-face cursor prices do not span 500-50000 gems');
const flagCodes=app.match(/const FLAG_REGION_CODES=`([^`]+)`\.split\(' '\)/)?.[1]?.split(' ')||[];
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
const oecdCodes=app.match(/const OECD_FLAG_CODES=new Set\('([^']+)'\.split\(' '\)\)/)?.[1]?.split(' ')||[];
assert(oecdCodes.length===38&&new Set(oecdCodes).size===38&&app.includes('OECD_FLAG_CURSOR_BASE_PRICE=20000')&&app.includes('FLAG_CURSOR_BASE_PRICE=10000'),'Flag cursor base prices or the 38-country OECD tier are missing');
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','イングランド'],['gbsct','スコットランド'],['gbwls','ウェールズ']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
const oecdCodes='AU AT BE CA CL CO CR CZ DK EE FI FR DE GR HU IS IE IL IT JP KR LV LT LU MX NL NZ NO PL PT SK SI ES SE CH TR GB US'.split(' ');
assert(oecdCodes.length===38&&oecdCodes.every(code=>storeCatalog.find(item=>item.id===`cursor-flag-${code.toLowerCase()}`)?.cost===20000)&&storeCatalog.filter(item=>item.id.startsWith('cursor-flag-')).every(item=>item.cost===10000||item.cost===20000),'Canonical flag prices or the 38-country OECD tier are missing');
const flagAssetDir=path.join(root,'assets','flags'),flagAssets=fs.readdirSync(flagAssetDir).filter(name=>name.endsWith('.svg'));
assert(flagAssets.length===262&&fs.existsSync(path.join(flagAssetDir,'LICENSE-TWEMOJI.txt')),'Bundled cross-platform flag SVG catalog or attribution is incomplete');
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'Native/DOM SVG-backed circular flag cursor rendering is missing');
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&!css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or smooth cursor continuity is missing');
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'DOM SVG-backed circular flag cursor rendering is missing');
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or drag-time cursor hiding is missing');
assert(css.includes('@font-face{font-family:"DotGothic16Local"')&&css.includes('--emoji-font:')&&css.includes('body,button,input,select,textarea{font-family:var(--dot-font)}')&&!css.includes(':root{--dot-font:"DotGothic16"')&&html.includes('id="customEmojiCursor"'),'Bundled Japanese dot font is overridden or emoji-specific isolation is missing');
assert(!html.includes('&#x6240;&#x6301;&#x30B8;&#x30A7;&#x30E0;')&&!functionSource('completionEffect').includes('ジェム')&&!functionSource('updateScoreLensBadge').includes('予想ジェム'),'Standalone gem terminology remains in the reward UI');
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded thirteen-item inventory');
assert(functionSource('seededStoreItemIds').includes('.slice(0,12)')&&functionSource('seededStoreItemIds').includes('.slice(0,1)')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist twelve seeded cursors and one seeded non-cursor item');
assert(functionSource('renderStorePanel').includes("{title:'アイテム'")&&functionSource('renderStorePanel').includes("{title:'カーソル'")&&functionSource('renderStorePanel').includes('if(category.cursor)card.append(icon,buy)')&&css.includes('.store-cursor-list{grid-template-columns:repeat(6'),'Shop is not split into item and horizontal twelve-cursor sections');
assert(functionSource('renderInventoryPanel').includes("'inventory-cursor-grid'")&&functionSource('renderInventoryPanel').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
assert(functionSource('makeStaticBoard').includes("openStoreMeta(meta)")&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded eighteen-item inventory');
assert(functionSource('seededStoreItemIds').includes('.slice(0,6)')&&functionSource('seededStoreItemIds').includes('6-fixedTools.length')&&functionSource('seededStoreItemIds').includes('item.scoreLens')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist six seeded cursors and six non-cursor items');
assert(['カーソル','その他のアイテム'].every(title=>functionSource('renderStorePanel').includes(`title:'${title}'`))&&functionSource('renderStorePanel').includes('.slice(0,6)')&&functionSource('renderStorePanel').includes('store-section-brief')&&css.includes('.store-compact-list{grid-template-columns:repeat(6'),'Shop is not arranged as six cursors above six described non-cursor items');
assert(functionSource('createInventoryCategoryView').includes("'inventory-cursor-grid'")&&functionSource('updateInventoryItemView').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
assert(!app.includes('static-shop-icon')&&functionSource('beginPan').includes('nearestStoreMetaAtWorldPoint')&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
assert(functionSource('discardUnmovedCreatedPath').includes('path.cells.length!==1')&&functionSource('bindBoard').includes('discardUnmovedCreatedPath(b)'),'Cancelled pickup creation can leave an orphan handle');
assert(functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number colors do not update in the live pickup renderer');
assert(functionSource('detachPathFromStartGate').includes('path.detachedStart=true')&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'")&&!app.includes('whitePickupEnd')&&!functionSource('finalizeAtGate').includes("'#fff'"),'Two-ended colored pickup support is incomplete');
assert(!functionSource('openTipMergePlan').includes('a.detachedStart||o.detachedStart')&&functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function gateConnectionSteps'),'Same-cell pickup joining or exact-cell gate snapping is incomplete');
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&/id:'score-lens'[^\n]+scoreLens:true[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&storeCatalog.find(item=>item.id==='score-lens')?.scoreLens===true&&/id:'score-lens'[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
assert(functionSource('renderStorePanel').includes('formatScore(price)')&&!functionSource('renderStorePanel').includes('price-data.score')&&!functionSource('purchaseStoreItem').includes('price-data.score'),'Store buttons do not always show the actual item price');
assert(functionSource('zoomAt').includes('MIN_CAMERA_SCALE'),'Camera cannot zoom out to overview scale');
assert(functionSource('addObstaclePattern').includes('puzzle.difficulty=sourcePuzzle.difficulty'),'Obstacle generation changes the displayed level and section constraint');
@ -143,12 +146,12 @@ assert(AppLogic.generatedShapeFamilyKey(familyA)===AppLogic.generatedShapeFamily
const balancedFamilies=AppLogic.balancedShapeCandidates([familyA,familyARotated,familyB],12345,{hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle}).slice(0,2).map(AppLogic.generatedShapeFamilyKey);
assert(new Set(balancedFamilies).size===2,'Shape balancing still exhausts one orientation-rich family before another family');
for(let level=1;level<=10;level++){
const range=AppLogic.sectionCountRange(level);assert(range.max===level&&range.min===Math.max(1,level-3),`Level ${level} section range is invalid`);
const range=AppLogic.sectionCountRange(level),expected=level<=3?{min:1,max:1}:{min:Math.max(1,level-3),max:level};assert(range.max===expected.max&&range.min===expected.min,`Level ${level} section range is invalid`);
const candidates=AppLogic.shapeCandidatesForLevel(0x470000+level,level,8,shapeDeps);assert(candidates.length>0,`Level ${level} has no section shapes`);
for(const shape of candidates){assert(shape.length>=range.min&&shape.length<=range.max,`Level ${level} generated ${shape.length} sections outside ${range.min}-${range.max}`);const set=new Set(shape.map(([x,y])=>`${x},${y}`));let reached=new Set([`${shape[0][0]},${shape[0][1]}`]),changed=true;while(changed){changed=false;for(const[x,y]of shape)if(!reached.has(`${x},${y}`)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy])=>reached.has(`${x+dx},${y+dy}`))){reached.add(`${x},${y}`);changed=true}}assert(reached.size===set.size,'Generated section shape is disconnected')}
}
const normalizeContext={AppLogic,LINE_COLORS:Array(10).fill('#000'),isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),ckey:(r,c)=>`${r},${c}`,sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),solverDifficulty:BendPuzzle.solverDifficulty,deepClone:value=>JSON.parse(JSON.stringify(value))};
const normalizeContext={AppLogic,LINE_COLORS:Array(10).fill('#000'),LINE_EFFECT_IDS:new Set(['glow','neon']),isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),ckey:(r,c)=>`${r},${c}`,sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),solverDifficulty:BendPuzzle.solverDifficulty,deepClone:value=>JSON.parse(JSON.stringify(value))};
vm.createContext(normalizeContext);
vm.runInContext([functionSource('normalizePath'),functionSource('normalizeSpecialCells'),functionSource('repairWarpNumberClues'),functionSource('normalizeStoredPuzzle'),functionSource('puzzleForStorage'),'this.logic={normalizeStoredPuzzle,puzzleForStorage}'].join('\n'),normalizeContext);
const normalized=normalizeContext.logic.normalizeStoredPuzzle(starter,[[0,0]],1);assert(normalized,'Starter fails stored-puzzle validation');
@ -157,7 +160,7 @@ const stored=normalizeContext.logic.puzzleForStorage(normalized);assert(stored.s
assert(functionSource('gateCandidateAtPoint').includes('gateCandidatesInCell')&&functionSource('gateStartCandidate').includes('gateCandidateAtPoint')&&functionSource('bindBoard').includes('gateStartCandidate(b,point,hintCell,directGate)'),'Gate and gate-cell input do not share one selector');
assert(functionSource('placeChildAtFrontierAttempt').includes('puzzleSupportsConnectionRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('fallbackShape=[[0,0]]')&&functionSource('placeChildAtFrontier').includes('frontierGeometryStillViable')&&functionSource('expandMetaNow').includes('missingGateConnections(meta)'),'Expansion lacks validated safe fallback or actual connection verification');
assert(functionSource('placeChildAtFrontierAttempt').includes('fixedPortProfilesForRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('portSeed')&&functionSource('placeChildAtFrontierAttempt').includes('specialSeed')&&functionSource('placeChildAtFrontierAttempt').includes('generatedPuzzleIssue'),'Failed boards are not fully regenerated with provisional gates and special cells');
assert(functionSource('closedVoidRepairCandidates').includes('closedVoidRepair:true')&&functionSource('repairExpansions').includes('closedVoidRepairCandidates().filter')&&functionSource('repairExpansions').includes('.slice(0,2)')&&functionSource('pendingExpansionCount').includes('closedVoidRepairCandidates().length'),'Saved fields do not detect and repair enclosed missing puzzle squares');
assert(functionSource('closedVoidRepairCandidates').includes('closedVoidRepair:true')&&functionSource('repairExpansions').includes('closedVoidRepairCandidates().filter')&&functionSource('repairExpansions').includes('.slice(0,2)')&&functionSource('pendingExpansionCount').includes('closedVoidRepairCandidates().filter'),'Saved fields do not detect and repair enclosed missing puzzle squares');
assert(functionSource('generatePuzzleAsync').includes('generationOptions')&&worker.includes('generationOptions || null'),'Generation options are not passed through the worker');
assert(functionSource('reopenMissingGateExpansions').includes('st.expanded=false')&&functionSource('reopenMissingGateExpansions').includes('missingGateConnections(meta)'),'Persisted false-positive expansion states are not reopened safely');
console.log(`BEND FIELD v${appVersion} source and shared-logic smoke test passed`);

View file

@ -3,19 +3,19 @@ const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
const context={
AppLogic,
SPECIAL_CELL_MIN_LEVEL:5,SPECIAL_CELL_DEBUG_ALL_LEVELS:false,data:{metas:{},specialMechanicsSeen:[]},
SPECIAL_CELL_MIN_LEVEL:5,data:{metas:{},specialMechanicsSeen:[]},
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,
deepClone:value=>JSON.parse(JSON.stringify(value)),key2:(x,y)=>`${x},${y}`,ckey:(r,c)=>`${r},${c}`,
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},OPP:{N:'S',S:'N',W:'E',E:'W'},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
};
vm.createContext(context);
vm.runInContext([
'specialCellSet','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
'specialCellSet','internalGateIndexes','internalGateIndexSet','isInternalGateIndex','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','addInternalGateSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addInternalGateSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
const logic=context.logic;
for(let level=1;level<=4;level++)assert(AppLogic.specialSchedule(level,100,level).types.length===0,`Level ${level} scheduled a production special`);
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing'],[]);
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing','internalGate'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing','internalGate'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing','internalGate'],[]);
assert(level7.setCount===2&&level8.setCount===3,'Level 7-8 special density does not increase deterministically');
{
const puzzle={valid:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]],g:[[2,0,'W'],[2,4,'S']],n:[[0,4,1]],specialCells:{crossings:[],warps:[{a:[2,1],b:[0,3]}],locks:[]},solution:[]},path={startGate:0,endGate:1,openGate:null,cells:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]]};
@ -43,13 +43,13 @@ assert(base,'Could not find a puzzle supporting warp and key/door special cells'
const warpedIndex=cached.solution.findIndex(path=>cached.specialCells.warps.some(pair=>path.cells.some(cell=>context.sameCell(cell,pair.a))));assert(warpedIndex>=0&&Number.isFinite(after[warpedIndex]),'Warp turn analysis did not run');
}
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} generated a production special`)}
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(low===base&&!special.internalGates.length&&!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} received a special mechanic before the production threshold`)}
const counts={warp:0,lock:0,cross:0,boards:0};let examples={};
for(let seed=0;seed<900;seed++){
context.data.specialMechanicsSeen=[];
context.data.specialMechanicsSeen=['internalGate'];
const puzzle=logic.addSpecialCellPattern(base,0x710000+seed,5);if(!puzzle)continue;const special=logic.specialCellSet(puzzle),
present=[special.warps.length&&'warp',special.locks.length&&'lock',special.crossings.length&&'cross'].filter(Boolean);
assert(present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter was not one minimum special type');
assert(special.internalGates.length===0&&present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter did not schedule exactly one production special');
if(special.warps.length){counts.warp++;examples.warp||=puzzle}
if(special.locks.length){counts.lock++;examples.lock||=puzzle}
if(special.crossings.length){counts.cross++;examples.cross||=puzzle}
@ -57,6 +57,12 @@ for(let seed=0;seed<900;seed++){
}
for(const [type,count] of Object.entries({warp:counts.warp,lock:counts.lock}))assert(count>0,`${type} was never selected by deterministic first-encounter scheduling`);
assert(counts.boards>0,'Level-5 scheduling generated no viable special boards');
for(let seed=0;seed<900&&!examples.internalGate;seed++){
context.data.specialMechanicsSeen=['warp','lock','crossing'];
const puzzle=logic.addSpecialCellPattern(base,0x720000+seed,5);
if(puzzle&&logic.specialCellSet(puzzle).internalGates.length===1)examples.internalGate=puzzle;
}
assert(examples.internalGate,'Internal gates were not introduced by the normal level-5 production schedule');
function verifyPuzzle(puzzle){
const special=logic.specialCellSet(puzzle),valid=new Set(puzzle.valid.map(cell=>context.ckey(...cell))),crossingKeys=new Set(special.crossings.map(cell=>context.ckey(...cell))),covered=new Map();
@ -71,6 +77,7 @@ function verifyPuzzle(puzzle){
assert(covered.size===valid.size,'Special solution does not cover every valid cell');
for(const pair of special.warps){let found=false;for(const path of puzzle.solution){const ai=path.cells.findIndex(cell=>context.sameCell(cell,pair.a)),bi=path.cells.findIndex(cell=>context.sameCell(cell,pair.b));if(ai>=0||bi>=0){assert(Math.abs(ai-bi)===1,'Warp endpoints are not consecutive');found=true}}assert(found,'Warp pair is absent from the solution')}
for(const lock of special.locks){let found=false;for(const path of puzzle.solution){const ki=path.cells.findIndex(cell=>context.sameCell(cell,lock.key)),di=path.cells.findIndex(cell=>context.sameCell(cell,lock.door));if(ki>=0||di>=0){assert(ki>=0&&di>ki,'Key is not before its door on the solution line');found=true}}assert(found,'Key/door pair is absent from the solution')}
for(const pair of special.internalGates||[]){const a=puzzle.g[pair.a],b=puzzle.g[pair.b],da=context.SIDE_D[a[2]],db=context.SIDE_D[b[2]];assert(context.manhattan(a,b)===1&&a[0]+da[0]===b[0]&&a[1]+da[1]===b[1]&&b[0]+db[0]===a[0]&&b[1]+db[1]===a[1],'Internal gate pair is not adjacent and mutually facing');assert(puzzle.solution.some(path=>path.startGate===pair.a||path.endGate===pair.a)&&puzzle.solution.some(path=>path.startGate===pair.b||path.endGate===pair.b),'Internal gate pair is not used as solution endpoints')}
const state={paths:context.deepClone(puzzle.solution),specialProgress:{crossings:special.crossings.map(cell=>context.ckey(...cell))}};assert(logic.isSolved(state,puzzle),'Generated special-cell solution does not solve its board');
for(const cell of special.crossings){const key=context.ckey(...cell),validSet=new Set(puzzle.valid.map(candidate=>context.ckey(...candidate))),gateSet=new Set(puzzle.g.map(g=>context.ckey(g[0],g[1])));assert(covered.get(key)===2,'Crossing solution does not use the crossing cell exactly twice');assert(!gateSet.has(key),'Crossing cell was placed on a gate cell');for(const neighbor of[[cell[0]-1,cell[1]],[cell[0]+1,cell[1]],[cell[0],cell[1]-1],[cell[0],cell[1]+1]])assert(validSet.has(context.ckey(...neighbor)),'Crossing cell was placed on the board edge')}
}
@ -85,4 +92,4 @@ for(const puzzle of Object.values(examples))verifyPuzzle(puzzle);
solvedState.paths[1].cells=[[0,2],[1,2],[1,1],[2,1],[3,1]];solvedState.specialProgress.crossings=['2,2'];assert(!logic.isSolved(solvedState,solvedPuzzle),'Crossing history permitted clear after the crossing state was removed');
}
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards`);
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards, plus production-scheduled internal gates`);

View file

@ -1,11 +1,12 @@
'use strict';
const {app,assert,functionSource,loadAppLogic}=require('./helpers/app-source');
const AppLogic=loadAppLogic();
const placeSource=functionSource('placeChildAtFrontier'),expandSource=functionSource('expandMetaNow'),metaStateSource=functionSource('metaState'),snapshotSource=functionSource('snapshotForStorage');
const placeSource=functionSource('placeChildAtFrontier'),expandSource=functionSource('expandMetaNow'),metaStateSource=functionSource('metaState'),ensureMetaStateSource=functionSource('ensureMetaState'),snapshotSource=functionSource('snapshotForStorage');
for(const forbidden of ['ensureBoards','renderAll','updateHud','nextPaint','save(','rebuildOccupancy'])assert(!placeSource.includes(forbidden),`Child placement still performs ${forbidden}`);
assert((expandSource.match(/refreshWorldView\(/g)||[]).length===1,'Expansion does not use one presentation/persistence commit');
for(const forbidden of ['ensureBoards','renderAll','updateHud'])assert(!expandSource.includes(forbidden),`Expansion bypasses the shared view pipeline with ${forbidden}`);
assert(metaStateSource.includes('normalizeState(state)'),'Live state normalization is not delegated to normalizeState');
assert(!metaStateSource.includes('normalizeState')&&!metaStateSource.includes('markStateDirty')&&ensureMetaStateSource.includes('normalizeState(state)'),'State reads are not pure or explicit state repair is not delegated to normalizeState');
assert(!functionSource('puzzleOf').includes('markMetaDirty')&&functionSource('repairPuzzleDifficulty').includes('markMetaDirty'),'Puzzle reads still dirty metadata or difficulty repair lacks an explicit command boundary');
assert(snapshotSource.includes('metaRowsForStorage()')&&snapshotSource.includes('stateRowsForStorage()'),'Snapshot serialization bypasses shared row serializers');
assert(functionSource('pushCloudPending').includes('cloudRowsForStorage')&&functionSource('cloudRowsForStorage').includes('metaForStorage')&&functionSource('cloudRowsForStorage').includes('stateForStorage'),'Cloud serialization bypasses full-detail row serializers');
assert(functionSource('stateForStorage').includes('AppLogic.stateForStorage'),'State serialization is not delegated to the shared module');

View file

@ -3,7 +3,7 @@
const http=require('http');
const path=require('path');
const {spawn}=require('child_process');
const {chromium}=require('playwright');
const {chromium}=require('playwright-core');
const {assert,root}=require('./helpers/app-source');
const port=61000+Math.floor(Math.random()*1000);
@ -39,8 +39,9 @@ async function waitForServer(){
const meta=data.metas.B0,st=metaState('B0'),itemIds=seededStoreItemIds(meta.seed);
st.store={owner:'UI TEST',pathIndex:0,cellIndex:0,itemIds,purchases:[],priceVersion:STORE_PRICE_VERSION,priceCoefficient:1};
data.score=1e9;openStoreMeta(meta);
const itemSection=document.querySelector('.store-items-section'),cursorSection=document.querySelector('.store-cursors-section'),
cursorCards=[...cursorSection.querySelectorAll('.store-item')],itemList=itemSection.querySelector('.store-section-list'),
const itemSections=[...document.querySelectorAll('.store-items-section')],cursorSection=document.querySelector('.store-cursors-section'),
cursorCards=[...cursorSection.querySelectorAll('.store-item')],itemCards=[...document.querySelectorAll('.store-other')],
itemList=itemSections.map(section=>section.querySelector('.store-section-list')).find(list=>list.querySelector('.store-item')),
cursorList=cursorSection.querySelector('.store-section-list'),flag=FLAG_CURSOR_ITEMS.find(item=>item.id==='cursor-flag-jp');
const itemColumns=getComputedStyle(itemList).gridTemplateColumns.split(' ').length,cursorColumns=getComputedStyle(cursorList).gridTemplateColumns.split(' ').length,
cursorVertical=cursorCards.every(card=>card.querySelector('.store-item-icon').getBoundingClientRect().bottom<=card.querySelector('.store-buy').getBoundingClientRect().top+1);
@ -49,7 +50,7 @@ async function waitForServer(){
const pricesAfter=[...document.querySelectorAll('.store-buy')].map(button=>button.textContent);
const storeResult={
headings:[...document.querySelectorAll('.store-section-title')].map(node=>node.textContent),
items:itemSection.querySelectorAll('.store-item').length,cursors:cursorCards.length,
items:itemCards.length,cursors:cursorCards.length,
cursorHasCopy:cursorCards.some(card=>card.querySelector('h4,p,strong,.store-item-copy')),
cursorIcons:cursorCards.map(card=>Boolean(card.querySelector('.store-item-icon')?.textContent||card.querySelector('.store-item-icon img')?.getAttribute('src'))),
itemColumns,cursorColumns,cursorVertical,
@ -93,12 +94,12 @@ async function waitForServer(){
if(!result.fontReady)console.log('Store UI diagnostics:',JSON.stringify(result));
assert(result.flags===262,'Flag cursor catalog is incomplete');
assert(result.fontReady&&/DotGothic16Local/.test(result.bodyFont)&&/DotGothic16Local/.test(result.buttonFont)&&/DotGothic16Local/.test(result.numberFont),'Bundled Japanese dot font did not load or was overridden');
assert(result.faceMin===500&&result.faceMax===50000&&result.flagBase===10000&&result.oecdBase===20000&&Math.abs(result.shopChance-1/30)<1e-12,'Cursor prices or the 1/30 shop chance are incorrect');
assert(result.faceMin===500&&result.faceMax===50000&&result.flagBase===10000&&result.oecdBase===20000&&Math.abs(result.shopChance-1/10)<1e-12,'Cursor prices or the 1/10 shop chance are incorrect');
assert(result.meta==='店主:UI TEST'&&!result.meta.includes('価格は固定'),'Fixed-price shop copy remains');
assert(result.headings.join('|')==='アイテム|カーソル','Shop sections are not separated');
assert(result.items===2&&result.cursors===12,'Shop does not render its 2+12 inventory');
assert(result.headings.includes('カーソル')&&result.headings.includes('その他のアイテム')&&result.headings.length===2,'Shop cosmetic, tool, and cursor sections are not separated');
assert(result.items===12&&result.cursors===6,'Shop does not render its 12+6 inventory');
assert(!result.cursorHasCopy&&result.cursorIcons.every(Boolean)&&result.actualPrices,'Cursor cards still expose names/descriptions, lack designs, or do not show actual prices');
assert(result.itemColumns===1&&result.cursorColumns===6&&result.cursorVertical,'Shop cursor designs are not horizontally arranged above their purchase buttons');
assert(result.itemColumns===6&&result.cursorColumns===6&&result.cursorVertical,'Shop cosmetics and cursor designs are not compact horizontal grids above their purchase buttons');
assert(result.inventoryOptions===1&&!result.inventoryHasCopy&&result.selectedOnce==='flag-jp'&&result.selectedTwice==='default',`Inventory cursor grid is not persistent or click-to-toggle: ${JSON.stringify({inventoryOptions:result.inventoryOptions,inventoryHasCopy:result.inventoryHasCopy,selectedOnce:result.selectedOnce,selectedTwice:result.selectedTwice})}`);
assert(result.lensInitial==='OFF'&&result.lensOn===true&&result.lensOnText==='ON'&&result.lensOff===false&&result.lensOffText==='OFF',`Score lens is not an ON/OFF inventory toggle: ${JSON.stringify({lensInitial:result.lensInitial,lensOn:result.lensOn,lensOnText:result.lensOnText,lensOff:result.lensOff,lensOffText:result.lensOffText})}`);
assert(!result.customText&&result.customImage.endsWith('assets/flags/1f1ef-1f1f5.svg')&&result.customVisible&&result.customOpacity==='0.76'&&result.customWidth==='14px'&&result.customRadius==='50%'&&result.customFlagFit==='cover','Japanese flag cursor is not a translucent circular knob-shaped SVG overlay');

View file

@ -4,10 +4,10 @@ const {assert,functionSource,app,css,html}=require('./helpers/app-source');
const root=path.resolve(__dirname,'..'),catalog=JSON.parse(fs.readFileSync(path.join(root,'store-catalog.json'),'utf8'));
assert(functionSource('positionBoardLabel').includes('viewportRect')&&functionSource('applyCamera').includes('repositionActiveBoardHud'),'Board HUD is not repositioned against viewport bounds');
assert(css.includes('.fps-stat{position:fixed')&&css.includes('left:calc(6px')&&css.includes('bottom:calc(5px')&&functionSource('refreshFpsCounter').includes('FPS 待機'),'FPS display is not bottom-left or idle-aware');
assert(functionSource('pointerEventSamples').includes('coalesced[coalesced.length-1]')&&functionSource('appendBoardPointerSamples').includes('setBoardPointerSample')&&functionSource('setBoardPointerSample').includes('b.pendingPointerMove=sample')&&functionSource('renderDragFrame').includes('renderGeometryRevision'),'Knob dragging still processes stale samples or recomputes static geometry every frame');
assert(functionSource('pointerEventSamples').includes('coalesced[coalesced.length-1]')&&functionSource('appendBoardPointerSamples').includes('setBoardPointerSample')&&functionSource('setBoardPointerSample').includes('scheduler.push(sample)')&&functionSource('renderDragFrame').includes('renderGeometryRevision'),'Knob dragging still processes stale samples or recomputes static geometry every frame');
assert(functionSource('makeBoard').includes("class:'board-input-surface'")&&css.includes('.board-input-surface{fill:transparent;cursor:crosshair;pointer-events:fill}')&&css.includes('.board-svg{cursor:crosshair;pointer-events:none}'),'Board input remains active across overlapping transparent SVG padding');
assert(css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px')&&css.includes('body[data-cursor-mode="dom"] *{cursor:none!important}')&&functionSource('updateCustomCursorFromPointer').includes("dataset.cursorMode==='dom'"),'Custom cursor size or UI-wide DOM fallback is missing');
assert(catalog.find(item=>item.id==='score-lens')?.cost===200000&&app.includes("id:'score-lens',name:'ジェムレンズ',cost:200000"),'Gem lens base price is not 200000');
assert(catalog.find(item=>item.id==='score-lens')?.cost===200000&&app.includes("id:'score-lens'")&&app.includes('CanonicalStoreCatalog.map'),'Gem lens base price is not sourced from the canonical catalog');
assert(app.includes('TIME_ATTACK_MINUTES=Object.freeze([3,5,10])')&&html.includes('data-time-minutes="3"')&&html.includes('data-time-minutes="5"')&&html.includes('data-time-minutes="10"'),'Time attack durations are not 3, 5, and 10 minutes');
assert(css.includes('.time-attack-panel{width:min(760px')&&html.includes('📋 結果をコピー')&&functionSource('timeAttackResultText').includes('⏱️'),'Time attack UI/result decoration is incomplete');
console.log('v47.71 HUD, input, cursor, economy, and time-attack regression test passed');

View file

@ -1,23 +1,23 @@
'use strict';
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path}=require('./helpers/app-source');
assert(app.includes("const APP_VERSION='47.77'")&&html.includes('v47.77'),'Version was not advanced to v47.77');
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path,buildMeta}=require('./helpers/app-source');
assert(buildMeta.APP_VERSION==='48.0'&&buildMeta.PACKAGE_VERSION==='48.0.0','Version was not advanced to v47.84');
assert(functionSource('makeBoard').includes('board-input-clip-')&&functionSource('renderBoardNow').includes("'clip-path':`url(#${b.inputClipId})`") ,'Endpoint hit regions are not clipped to their owning board');
assert(functionSource('sharedBoundaryColorIndex').includes('boundaryColorSource')&&functionSource('renderBoardNow').includes('sharedBoundaryColorIndex(meta,i)'),'Facing gate colors are not unified');
assert(functionSource('renderBoardNow').includes('else renderDragFrame(b)'),'Stationary drag cursor is not restored after a full board redraw');
assert(functionSource('bindBoard').includes('b.drawing=null;b.armedGate=null')&&!functionSource('bindBoard').includes('b.drawing.pointerId=e.pointerId'),'Released drawings can still be resumed from unrelated cells');
assert(functionSource('radialReactionIndex').includes('bestDistance')&&functionSource('beginReactionGesture').includes('setPointerCapture')&&app.includes("window.addEventListener('pointermove',moveReactionGesture,true)"),'Radial hold-drag-release selection is incomplete');
assert(html.includes('id="settingsBtn"')&&html.includes('id="settingsPlayerName"')&&html.includes('id="lightweightRenderingToggle"')&&html.includes('id="soundEnabledToggle"')&&functionSource('saveSettings').includes('/api/cloud/profile')&&functionSource('init').includes('createAutomaticPlayerName'),'Settings-based player naming, rendering, sound, or automatic initial name is missing');
assert(html.includes('id="settingsBtn"')&&html.includes('id="settingsPlayerName"')&&html.includes('id="lightweightRenderingToggle"')&&html.includes('id="soundEnabledToggle"')&&functionSource('saveSettings').includes('commitPlayerProfileName')&&functionSource('commitPlayerProfileName').includes('/api/cloud/profile')&&functionSource('init').includes('createAutomaticPlayerName'),'Settings-based player naming, rendering, sound, or automatic initial name is missing');
assert(functionSource('soundTone').includes('if(!uiSettings.soundEnabled)return')&&functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering'"),'Settings are not applied to sound and rendering');
assert(functionSource('renderBoardNow').includes('st.solvedBy!==LEGACY_LOCAL_SOLVER'),'Legacy 「あなた」 solver labels are still displayed');
assert(css.includes('.solver-badge strong{display:block;max-width:100%;white-space:nowrap')&&css.includes('text-overflow:ellipsis'),'Solver names can still wrap repeatedly');
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'"),'North/south board HUD does not extend to the board edge');
assert(functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)'),'Board HUD is not detached and clamped to the viewport');
assert(css.includes('#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%')&&css.includes('transform:translate(-50%,-50%)'),'Flag cursor is not centered');
assert(css.includes('.fps-stat{position:fixed')&&css.includes('background:none')&&css.includes('font-size:8px'),'FPS display is not subtle and background-free');
assert(html.includes('長くつながって太くなった線ほど高得点です。'),'Help does not explain long/thick-line scoring');
assert(functionSource('rebuildMinimapWorld').includes('drawMapBoardCells')&&!functionSource('drawMapBoardCells').includes('stroke('),'Minimap board outlines are still drawn');
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not match the minimap');
assert(functionSource('maybeGrantGenerationFailureBonus').includes('GENERATION_FAILURE_MIN_MS')&&functionSource('maybeGrantGenerationFailureBonus').includes('unresolvedExpansionCandidates(meta).length'),'Generation failure bonus is not limited to long unresolved expansion attempts');
const server=read('server.js');assert(server.includes("row.state.expanded===true")&&server.includes("Expansion already succeeded")&&server.includes('GENERATION_FAILURE_BONUS = 2500'),'Generation bonus server guard or level-6-equivalent amount is missing');
const server=`${read('server.js')}\n${read('server/player-service.js')}`;assert(server.includes("row.state.expanded===true")&&server.includes("Expansion already succeeded")&&server.includes('GENERATION_FAILURE_BONUS = 2500'),'Generation bonus server guard or level-6-equivalent amount is missing');
const internal=read('docs/internal-system.md');assert(internal.includes('Do not add changelog')&&internal.includes('update the relevant current specification in place'),'Documentation policy does not prohibit update-history documents');
for(const name of fs.readdirSync(path.join(root,'docs')))assert(!/(changelog|release|history|update|v\d+|phase\d+)/i.test(name),`Historical update document remains: ${name}`);
const BendPuzzle=loadBendPuzzle();let hasLevel10Region=false,hasLevel6Region=false;
@ -26,4 +26,4 @@ assert(hasLevel6Region&&hasLevel10Region,'World difficulty map still lacks level
const high=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1]],987654,6,12,-9);
assert(high.difficulty===6&&high.complexity.rawRating>=6,'Level-6 regional generation is still discarded or misclassified');
assert(functionSource('normalizeStoredPuzzle').includes('solverDifficulty(puzzle,targetLevel)'),'Stored high-level boards lose their regional level after reload');
console.log('v47.77 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
console.log('v47.84 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');

View file

@ -1,6 +1,6 @@
'use strict';
const {assert,functionSource,app,css}=require('./helpers/app-source');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes('setActiveBoard(b.id)'),'Board HUD does not persist on the selected board');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&functionSource('selectBoard').includes('setActiveBoard(b.id)')&&!functionSource('selectBoard').includes('hudBoardId=b.id'),'Board HUD is not pinned by committed knob manipulation');
assert(functionSource('rebuildWorldOverviewCache').includes('{showLevels:true}')&&functionSource('drawMapBoardCells').includes('showLevels&&!solved')&&functionSource('rebuildMinimapWorld').includes('drawMapBoardCells(base,visibleMetas,mapX,mapY,scale)'),'Overview levels are missing or leaked into the minimap');
assert(functionSource('directionFromDelta').includes("preferredAxis!=='V'")&&!functionSource('directionFromDelta').includes('largest<smallest*POINTER_DOMINANT_RATIO'),'Diagonal pointer motion is still rejected');
assert(functionSource('extendPointerTo').includes('from=boardCellCenter(path.cells[path.cells.length-1])'),'Diagonal traversal still starts from a stale raw pointer location');
@ -8,4 +8,4 @@ assert(functionSource('renderDragFrame').includes("setSvgAttr(cache.liveTail,'x2
assert(functionSource('renderDragFrame').includes('blended=false')&&functionSource('renderBoardNow').includes('blended=!uiSettings.lightweightRendering')&&functionSource('pathColorAtCell').includes('uiSettings.lightweightRendering'),'Lightweight rendering still blends line colors');
assert(functionSource('refreshDragNumberColors').includes('drawing.dragNumberKeys')&&!functionSource('refreshDragNumberColors').includes('for(const[key,node]of b.numberNodes'),'Drag number styling still scans every number each step');
assert(css.includes('.drag-tip-group{pointer-events:none;will-change:transform;transform-origin:0 0}')&&css.includes('body.lightweight-rendering .path{filter:none!important;mix-blend-mode:normal!important}'),'Drag transform or lightweight path styles are missing');
console.log('v47.75 selected HUD, cached overview, diagonal drag, and drag performance regression test passed');
console.log('v47.84 active HUD, cached overview, diagonal drag, and drag performance regression test passed');

View file

@ -1,6 +1,6 @@
'use strict';
const {assert,functionSource,app}=require('./helpers/app-source');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes("classList.add('hud-current')"),'HUD must remain attached to the selected board');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&!functionSource('selectBoard').includes('hudBoardId=b.id'),'HUD must be activated by manipulation and remain on that board after release');
assert(functionSource('drawWorldOverview').includes('overviewCache')&&functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform')&&functionSource('drawWorldOverview').includes('drawImage(overviewBase'),'Overview panning must move a cached raster layer without viewport-sized frame copies');
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells'),'Overview cache rebuild is missing');
assert(!functionSource('queueRealtimeCursor').includes('schedulePresenceRender'),'Local cursor movement must not repaint the remote-presence canvas');

View file

@ -1,14 +1,17 @@
'use strict';
const {assert,functionSource,app,css}=require('./helpers/app-source');
const {assert,functionSource,app,css,buildMeta}=require('./helpers/app-source');
const fs=require('fs'),path=require('path'),frameScheduler=fs.readFileSync(path.join(__dirname,'../client/input/frame-scheduler.js'),'utf8');
for(const name of ['scheduleBoardDragFrame','queueCameraInteraction','scheduleWorldOverview','schedulePresenceRender','scheduleReactionRender']){
const source=functionSource(name);
if(name==='queueCameraInteraction')assert(source.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Camera missed-vsync fallback does not cancel its paired scheduler');
else if(name==='scheduleBoardDragFrame')assert(source.includes('DRAG_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
if(name==='queueCameraInteraction')assert(source.includes('cameraInteractionScheduler.push')&&frameScheduler.includes('clearArmed()'),'Camera missed-vsync fallback does not cancel its paired scheduler');
else if(name==='scheduleBoardDragFrame')assert(source.includes('requestFrame')&&frameScheduler.includes('clearArmed()'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
else if(name==='scheduleReactionRender')assert(source.includes('reactionDelayTimer=setTimeout')&&source.includes('reactionNextDrawAt'),'Reaction rendering does not use its deadline timer to avoid high-refresh RAF polling');
else if(name!=='scheduleWorldOverview')assert(!source.includes('setTimeout('),`${name} still double-throttles through setTimeout plus requestAnimationFrame`);
assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
if(name==='scheduleBoardDragFrame'||name==='queueCameraInteraction')assert(frameScheduler.includes('requestFrame(step)'),`${name} must use the shared frame-synchronized lane`);
else assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
}
const minimap=functionSource('scheduleMinimap');
assert(minimap.includes("classList?.contains?.('is-interacting')"),'Minimap must not repaint during an active gesture');
assert(minimap.includes("interactionActive('overview')"),'Minimap must not repaint during an active gesture');
const overview=functionSource('drawWorldOverview');
assert(functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform'),'Overview panning must move the cached bitmap as a compositor layer');
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback'),'Overview cache rebuilds must be deferred to idle time');
@ -18,8 +21,8 @@ const camera=functionSource('applyCamera');
assert(camera.includes('translate3d('),'Nearby field panning must use a compositor transform');
assert(camera.includes('shiftOnlineLayersForCamera'),'Online canvases must move without full repaint during camera gestures');
assert(camera.includes('minimapDirty=true'),'Camera movement must mark, not immediately repaint, the minimap');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id'),'Selected-board HUD persistence changed unexpectedly');
assert(css.includes('#viewport.panning::after{display:none}'),'Full-screen vignette must be suppressed while panning');
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)'),'Persistent manipulated-board HUD gating changed unexpectedly');
assert(!css.includes('#viewport.panning::after{display:none}')&&!css.includes('#world.camera-interacting'),'Panning still activates an automatic lightweight visual mode');
assert(css.includes('#presenceCanvas,#reactionCanvas{transform-origin:0 0'),'Online canvases are not compositor-ready');
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
console.log('v47.77 compositor frame-pipeline regression test passed');
assert(buildMeta.APP_VERSION==='48.0','Application version was not advanced');
console.log('v47.84 compositor frame-pipeline regression test passed');

View file

@ -1,8 +1,8 @@
'use strict';
const {assert,functionSource,app,css}=require('./helpers/app-source');
const {assert,functionSource,app,css,buildMeta}=require('./helpers/app-source');
const hud=functionSource('boardPlayHudVisible'),select=functionSource('selectBoard'),center=functionSource('centerMeta');
assert(hud.includes('hudBoardId===b?.id'),'HUD must use explicit session selection, not drag/claim state');
assert(select.includes('hudBoardId=b.id')&&select.includes("classList.add('hud-current')"),'Click selection must persist the board HUD');
assert(hud.includes('hudBoardId===b?.id'),'HUD must remain visible for the last board whose knob was manipulated');
assert(!select.includes('hudBoardId=b.id')&&functionSource('activateBoardHud').includes('hudBoardId=b.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)'),'Click selection must not expose the HUD, while a committed knob drag must pin it');
assert(center.includes('select=true')&&app.includes("centerMeta(initialMeta||data.metas.B0,{select:false})"),'Startup camera centering must not select an untouched board');
assert(functionSource('resetSelectedBoard').includes('rendered.get(hudBoardId)'),'Reset must target the persistent HUD selection');
assert(!css.includes("body.is-drawing .board-card:not(.input-active) .static-layer"),'Non-selected boards must not be dimmed while drawing');
@ -10,11 +10,11 @@ const sanitize=functionSource('sanitizeStateForPuzzle');
assert(sanitize.includes('path.detachedStart&&path.endGate==null')&&sanitize.includes('warpKeys.has'),'Loose lines with both knobs on warp cells must be removed');
const realtime=functionSource('queueRealtimeCursor');
assert(realtime.includes("classList.contains('is-drawing')")&&realtime.includes('realtimePendingCursorClient')&&realtime.indexOf('worldUnitAtClient')>realtime.indexOf('setTimeout'),'Raw pointer events must not perform realtime world conversion or send during drawing');
const custom=functionSource('updateCustomCursorFromPointer');
assert(custom.includes("dataset.cursorMode==='dom'")&&custom.includes('requestAnimationFrame')&&!custom.includes('FRAME_INTERVAL'),'DOM cursor fallback must remain visible during drag and commit at display cadence');
assert(functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('data-cursor-mode="native"'),'Static cursor skins must prefer the native cursor path');
const customInput=functionSource('updateCustomCursorFromPointer'),customCommit=functionSource('commitCustomCursorFrame');
assert(customInput.includes("dataset.cursorMode==='dom'")&&customInput.includes("classList.contains('is-drawing')")&&customInput.includes('scheduleCustomCursorFrame()')&&!customInput.includes('style.transform')&&customCommit.includes('style.transform')&&functionSource('scheduleCustomCursorFrame').includes('requestAnimationFrame(paint)'),'DOM cursor fallback must hide during knob manipulation and commit only through the capped display lane');
assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&css.includes('data-cursor-mode="dom"')&&css.includes('.board-input-surface')&&css.includes('body.is-drawing #viewport'),'Static cursor skins must cover uncleared board input surfaces and hide during manipulation');
const drag=functionSource('extendPointerTo'),render=functionSource('renderDragFrame');
assert(drag.includes('lastModelProbeKey')&&drag.includes('probeStep=6'),'Drag model work must be quantized instead of repeated for every raw move');
assert(render.includes('blended=false')&&render.includes('lastRenderedTip')&&render.includes('setSvgAttr'),'Live drag rendering must avoid gradients and redundant SVG writes');
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
console.log('v47.77 HUD, warp cleanup, cursor, and drag performance regression test passed');
assert(buildMeta.APP_VERSION==='48.0','Application version was not advanced');
console.log('v47.84 active HUD, cursor coverage, and capped knob tracking regression test passed');

View file

@ -1,73 +1,44 @@
'use strict';
const {assert,functionSource,app,css,vm}=require('./helpers/app-source');
const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(path.join(__dirname,'browser-performance-benchmark.js'),'utf8');
const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(path.join(__dirname,'browser-performance-benchmark.js'),'utf8'),
frameScheduler=fs.readFileSync(path.join(__dirname,'../client/input/frame-scheduler.js'),'utf8'),
dragModule=fs.readFileSync(path.join(__dirname,'../client/input/drag.js'),'utf8');
assert(app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=18'),'Interaction and auxiliary frame budgets are not separated');
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=34'),'Interaction and auxiliary frame budgets are not separated');
assert(app.includes('BEND_INTERACTION_SCHEDULER')&&app.includes('bend-field-interaction-scheduler-variant')&&app.includes('batteryDischargingTime'),'Scheduler rollout variant or battery telemetry guardrail is missing');
assert(app.includes('DRAG_MAX_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
assert(app.includes('DRAG_MAX_LIVE_CATCHUP_CELLS=6')&&app.includes('DRAG_MAX_RELEASE_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
const cursor=functionSource('updateCustomCursorFromPointer'),cursorSync=functionSource('syncCursorAppearance');
assert(cursor.includes('requestAnimationFrame')&&!cursor.includes('FRAME_INTERVAL'),'DOM cursor fallback is not synchronized to every display frame');
const cursorInput=functionSource('updateCustomCursorFromPointer'),cursorCommit=functionSource('commitCustomCursorFrame'),cursorSync=functionSource('syncCursorAppearance');
assert(cursorInput.includes('scheduleCustomCursorFrame()')&&!cursorInput.includes('customEmojiCursor.style.transform')&&cursorCommit.includes('customEmojiCursor.style.transform=`translate3d(')&&functionSource('scheduleCustomCursorFrame').includes('customCursorLastDraw+DRAG_FRAME_INTERVAL')&&functionSource('scheduleCustomCursorFrame').includes('requestAnimationFrame(paint)')&&!cursorCommit.includes('Math.exp('),'DOM cursor does not coalesce the latest pointer sample through its display-backed 60 Hz commit lane');
assert(app.includes("addEventListener('pointermove',updateCustomCursorFromPointer")&&app.includes("addEventListener('pointerrawupdate',updateCustomCursorFromPointer"),'DOM cursor does not tolerate sparse raw-input delivery');
assert(cursor.includes('customCursorActiveUntil=perfNow()+40')&&cursor.includes('customCursorFrame=requestAnimationFrame(step)')&&cursor.includes('Math.exp(-elapsed/8)'),'DOM cursor does not keep and smooth its display-rAF loop between active pointer events');
assert(!cursor.includes("classList.contains('is-drawing')")&&!css.includes('body.is-drawing #customEmojiCursor'),'Custom cursor is hidden during pickup dragging');
assert(cursorSync.includes('nativeSupported')&&cursorSync.includes('--active-native-cursor')&&css.includes('data-cursor-mode="native"'),'Native cursor assets or the DOM fallback switch are missing');
const cursorClasses=new Set(),cursorFrames=[],cursorRafQueue=[],cursorTransforms=[],cursorPerfSamples=new Map();
let cursorClock=0,cursorRafId=0;
const cursorContext={
document:{body:{dataset:{cursorMode:'dom'}}},
customEmojiCursor:{classList:{contains:name=>cursorClasses.has(name),add:name=>cursorClasses.add(name),remove:name=>cursorClasses.delete(name)},style:{set transform(value){cursorTransforms.push(value)}}},
perfNow:()=>cursorClock,
requestAnimationFrame:callback=>{cursorRafQueue.push(callback);return++cursorRafId},
markVisualFrame:timestamp=>cursorFrames.push(timestamp),
perfObserve:(name,value)=>{const samples=cursorPerfSamples.get(name)||[];samples.push(value);cursorPerfSamples.set(name,samples);return value},
perfCount:()=>{}
};
vm.createContext(cursorContext);
vm.runInContext(`const interactionCommitAt=Object.create(null),interactionInputAt=Object.create(null);${functionSource('recordInteractionCommit')}let customCursorFrame=0,customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorActiveUntil=0,customCursorRenderedX=NaN,customCursorRenderedY=NaN,customCursorLastFrameAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0;${cursor};this.updateCustomCursorFromPointer=updateCustomCursorFromPointer;`,cursorContext);
let cursorEventAt=0;
for(let frameAt=1000/60;frameAt<=1100;frameAt+=1000/60){
while(cursorEventAt<=1000&&cursorEventAt<=frameAt+.001){
cursorClock=cursorEventAt;cursorContext.updateCustomCursorFromPointer({clientX:cursorEventAt,clientY:cursorEventAt/2,timeStamp:cursorEventAt||.001,pointerType:'mouse',target:{isConnected:true}});
cursorEventAt+=1000/30;
}
cursorClock=frameAt;
for(const callback of cursorRafQueue.splice(0))callback(frameAt);
}
const cursorFrameGaps=cursorFrames.slice(1).map((timestamp,index)=>timestamp-cursorFrames[index]).sort((a,b)=>a-b),
cursorMedianGap=cursorFrameGaps[Math.floor((cursorFrameGaps.length-1)*.5)],
sortedCursorAges=[...(cursorPerfSamples.get('cursorInputAge')||[])].sort((a,b)=>a-b),cursorP95Age=sortedCursorAges[Math.floor((sortedCursorAges.length-1)*.95)],
measuredCursorGaps=[...(cursorPerfSamples.get('cursorFrameGap')||[])].sort((a,b)=>a-b),measuredCursorMedianGap=measuredCursorGaps[Math.floor((measuredCursorGaps.length-1)*.5)],
changedCursorTransforms=cursorTransforms.filter((value,index)=>index===0||value!==cursorTransforms[index-1]).length;
assert(cursorFrames.length>=60&&cursorMedianGap<=20,`Warm DOM cursor loop produced ${cursorFrames.length} frames with a ${cursorMedianGap} ms median gap`);
assert(measuredCursorGaps.length>=30&&measuredCursorMedianGap<=20,`Production cursor instrumentation captured ${measuredCursorGaps.length} gaps with a ${measuredCursorMedianGap} ms median`);
assert(changedCursorTransforms>=55,`Warm DOM cursor loop changed its transform on only ${changedCursorTransforms} display frames`);
assert(sortedCursorAges.length>=25,`Production cursor instrumentation captured only ${sortedCursorAges.length} fresh-input latency samples`);
assert(cursorP95Age<25,`Warm DOM cursor loop produced ${cursorP95Age} ms p95 input age`);
assert(cursorInput.includes("classList.contains('is-drawing')")&&css.includes('body.is-drawing #customEmojiCursor'),'Custom cursor is not hidden during pickup dragging');
assert(cursorSync.includes('dataset.cursorMode=presentation.mode')&&!cursorSync.includes('nativeSupported')&&!css.includes('data-cursor-mode="native"'),'Custom cursor skins are not forced through the board-safe DOM renderer');
const cameraQueue=functionSource('queueCameraInteraction'),cameraApply=functionSource('applyCamera');
assert(cameraQueue.includes('pendingCameraInteraction=next')&&cameraQueue.includes('requestAnimationFrame')&&cameraQueue.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&cameraQueue.includes('cancelAnimationFrame')&&cameraQueue.includes('cameraInteractionLastDraw+DRAG_FRAME_INTERVAL'),'Camera does not use a latest-input 60 Hz queue with a missed-vsync watchdog');
assert(cameraQueue.includes('pendingCameraInteraction=next')&&cameraQueue.includes('cameraInteractionScheduler.push(next)')&&app.includes('watchdogDelay:CAMERA_DISPLAY_WATCHDOG_MS')&&frameScheduler.includes('requestFrame(step)'),'Camera does not use a latest-input 60 Hz queue with a missed-vsync watchdog');
assert(functionSource('zoomAt').includes('queueCameraInteraction')&&!functionSource('zoomAt').includes('applyCamera(')&&app.includes("window.addEventListener('pointermove',movePan,true)")&&app.includes("window.addEventListener('pointerup',stopPan,true)")&&app.includes("viewport.addEventListener('lostpointercapture',stopPan,true)"),'Wheel zoom bypasses the capped camera lane or pan lifecycle is not resilient outside the viewport');
assert(cameraApply.includes('positionCachedWorldOverview')&&functionSource('positionCachedWorldOverview').includes('translate3d'),'Overview position is not updated in the camera fast path');
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback')&&functionSource('scheduleWorldOverview').includes("classList.contains('is-interacting')"),'Overview rebuilds are not idle and interaction-safe');
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback')&&functionSource('scheduleWorldOverview').includes("interactionActive('overview')"),'Overview rebuilds are not idle and interaction-safe');
const dragSchedule=functionSource('scheduleBoardDragFrame'),dragFrame=functionSource('processBoardDragFrame');
assert(dragSchedule.includes('requestAnimationFrame')&&dragSchedule.includes('DRAG_DISPLAY_WATCHDOG_MS')&&dragSchedule.includes('cancelAnimationFrame')&&dragSchedule.includes('clearTimeout')&&dragSchedule.includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&dragSchedule.includes('INTERACTION_FRAME_TOLERANCE_MS')&&dragFrame.includes('DRAG_FRAME_INTERVAL'),'Pickup visuals or logic are not capped to their 60 Hz lane');
assert(functionSource('queueBoardPointerMove').includes('appendBoardPointerSamples')&&functionSource('queueBoardPointerMove').includes('commitBoardDragFromInputDeadline')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&!functionSource('queueBoardPointerMove').includes('markVisualFrame')&&!functionSource('queueBoardPointerMove').includes('extendPointerTo'),'Pointer events mutate presentation or model work outside the capped scheduler');
assert(functionSource('commitBoardDragFromInputDeadline').includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&functionSource('commitBoardDragFromInputDeadline').includes('INTERACTION_FRAME_TOLERANCE_MS')&&functionSource('commitBoardDragFromInputDeadline').includes('processBoardDragFrame(b,now)'),'Pickup input deadline fallback is not governed by the 60 Hz presentation ceiling');
assert(dragSchedule.includes('requestFrame')&&functionSource('ensureBoardDragScheduler').includes('watchdogDelay:DRAG_DISPLAY_WATCHDOG_MS')&&functionSource('ensureBoardDragScheduler').includes('interval:DRAG_FRAME_INTERVAL')&&dragModule.includes("'idle','armed','running','draining','settling','cancelled'"),'Pickup visuals or logic are not capped to their 60 Hz lane');
assert(functionSource('queueBoardPointerMove').includes('appendBoardPointerSamples')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&!functionSource('queueBoardPointerMove').includes('markVisualFrame')&&!functionSource('queueBoardPointerMove').includes('extendPointerTo'),'Pointer events still directly mutate pickup presentation or perform forbidden model work');
assert(!app.includes('function commitBoardDragFromInputDeadline('),'Pickup still has a direct input-deadline commit path outside the shared 60 Hz scheduler');
assert(!app.includes('DRAG_INPUT_RESCUE_MS')&&!app.includes('function rescuePickupVisualFromInput('),'An input-rate pickup presentation path can still exceed 60 Hz');
assert(functionSource('setBoardPointerSample').includes('dragVisualActiveUntil=perfNow()+40')&&dragFrame.includes('Math.exp(-visualElapsed/8)')&&dragFrame.includes('updateDrawingHandlePosition')&&dragFrame.includes('perfNow()<b.dragVisualActiveUntil'),'Pickup visual lane does not remain warm and ease toward sparse pointer samples at display cadence');
assert(functionSource('setBoardPointerSample').includes('dragVisualActiveUntil=perfNow()+40')&&dragFrame.includes('b.dragVisualX=move.clientX')&&dragFrame.includes('updateDrawingHandlePosition')&&dragFrame.includes('perfNow()<b.dragVisualActiveUntil')&&!dragFrame.includes('visualBlend'),'Pickup visual lane does not track the latest pointer sample directly');
assert(functionSource('renderDragFrame').includes('translate3d')&&functionSource('renderDragFrame').includes('tailNow-drawing.lastTailRenderAt>=32'),'Complex pickup handles are not compositor-driven or the SVG live tail is repainted every display frame');
assert(dragFrame.includes('updatePickupHandleOverlay')&&dragFrame.includes('usesLightweightDragOverlay')&&dragFrame.includes('dirtyBoards.delete(b)')&&app.includes('LIGHTWEIGHT_DRAG_BOARD_CELLS=120')&&functionSource('updatePickupHandleOverlay').includes('translate3d')&&css.includes('#pickupHandleOverlay{position:fixed'),'Large-board pickup still repaints the SVG during the gesture instead of using the lightweight display-rate handle');
assert(functionSource('activateBoardPointerDrag').includes('setPickupScenePresentation(b,true)')&&functionSource('setLightweightDragPresentation').includes("style.opacity=next?'0':''")&&functionSource('setPickupScenePresentation').includes('setLightweightDragPresentation(board,next&&board===activeBoard)')&&functionSource('scheduleInteractionSettlePresentation').includes('setPickupScenePresentation(null,false)'),'Active large-board decorative layers are not compositor-suppressed during pickup and restored after settlement');
assert(dragFrame.includes("recordInteractionCommit('pickupVisual'")&&dragFrame.includes('workDuration'),'Pickup cadence, input age, or callback work is not instrumented');
assert(dragFrame.includes('timestamp<=b.dragVisualActiveUntil')&&dragFrame.includes("recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false)")&&css.includes('transition:transform 16.67ms linear'),'Pickup sample gaps are not compositor-interpolated or warm visual ticks are not measured');
assert(dragFrame.includes('timestamp<=b.dragVisualActiveUntil')&&dragFrame.includes("recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false)")&&!css.includes('transition:transform 16.67ms linear'),'Pickup warm visual ticks are not measured or the knob overlay still adds transition lag');
assert(dragFrame.includes('freshVisualInput')&&dragFrame.includes('freshLogicalInput'),'Pickup latency instrumentation resamples stale input on warm display frames');
assert(app.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4')&&app.includes('DRAG_MODEL_BUDGET_MS=.5')&&dragFrame.includes('b.pointerMoveSamples.shift()')&&dragFrame.includes('perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS')&&dragFrame.includes('logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length)'),'Pickup logic does not time-bound ordered-sample catch-up or warm frames still run model traversal without pending work');
assert(functionSource('setBoardPointerSample').includes('trimBoardPointerSamples(samples)')&&functionSource('trimBoardPointerSamples').includes('leastTurn')&&functionSource('trimBoardPointerSamples').includes('samples.splice(removeIndex,1)'),'Bounded pickup input overflow does not preserve sharp turns');
assert(app.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4')&&app.includes('DRAG_MODEL_BUDGET_MS=.5')&&dragFrame.includes('scheduler.takeLogical(1)')&&dragFrame.includes('perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS')&&dragFrame.includes('logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||scheduler.hasLogical())'),'Pickup logic does not time-bound ordered-sample catch-up or warm frames still run model traversal without pending work');
assert(functionSource('ensureBoardDragScheduler').includes('trim:trimBoardPointerSamples')&&functionSource('trimBoardPointerSamples').includes('leastTurn')&&functionSource('trimBoardPointerSamples').includes('samples.splice(removeIndex,1)'),'Bounded pickup input overflow does not preserve sharp turns');
assert(dragFrame.includes('pointerInsideBoardScreen(b,logicalMove)?eventToSvg')&&functionSource('pointerInsideBoardScreen').includes('boardScreenRect'),'Off-board edge panning can still traverse and prematurely finish the pickup path');
assert(dragFrame.includes("perfEnd('pickupModelWork'")&&dragFrame.includes("perfEnd('pickupVisualWork'"),'Pickup model and visual callback costs are not independently instrumented');
assert(functionSource('clearDragRender').includes("style.display='none'")&&!functionSource('clearDragRender').includes('replaceChildren'),'Drag cache nodes are destroyed between gestures');
const releaseDrain=functionSource('processBoardPointerReleaseDrain'),releaseFlush=functionSource('flushBoardPointerMove');
assert(releaseDrain.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME')&&releaseDrain.includes('DRAG_MODEL_BUDGET_MS')&&releaseDrain.includes('DRAG_MAX_RELEASE_CATCHUP_CELLS')&&releaseDrain.includes('scheduleBoardPointerReleaseDrain(b)')&&!releaseFlush.includes('Infinity'),'Pointer release does not drain final intent in bounded frame slices');
const bind=functionSource('bindBoard');
assert(bind.indexOf('beginPendingClaimPointer')<bind.indexOf('await ensureBoardClaimForInput'),'Shared-session pickup preview does not start before the ownership round-trip');
@ -76,18 +47,19 @@ assert(bind.includes('activateBoardPointerDrag')&&functionSource('activateBoardP
assert(functionSource('selectBoard').includes('alreadySelected')&&functionSource('renderDragFrame').includes('pickupStartFullBoardRenders'),'Pickup start does not avoid and measure full-board redraws');
assert(functionSource('applyBoardCommand').includes('pointerInteraction&&b.drawing?.pointerId==null')&&bind.includes('b.drawing=null;b.armedGate=null;refreshInteractionState()')&&bind.includes('scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex})'),'Pickup completion does not end interaction state before deferring broad render and cache work');
assert(functionSource('safeRelease').includes('scheduleInteractionSettlePresentation')&&!functionSource('safeRelease').includes('queueMicrotask')&&functionSource('scheduleInteractionSettlePresentation').includes('requestAnimationFrame')&&!functionSource('scheduleInteractionSettlePresentation').includes('ensureBoards()'),'Pickup release still runs broad settlement work inside the pointer task');
assert(functionSource('finalizeAtGate').includes('deferSettlement:true')&&functionSource('scheduleBoardCommandSettlement').includes('requestAnimationFrame')&&functionSource('scheduleBoardCommandSettlement').includes("classList.contains('is-interacting')"),'Pickup completion still performs render, persistence, or solve settlement inside the active drag callback');
assert(functionSource('finalizeAtGate').includes('deferSettlement:true')&&functionSource('scheduleBoardCommandSettlement').includes('requestAnimationFrame')&&functionSource('scheduleBoardCommandSettlement').includes("interactionActive('persistence')"),'Pickup completion still performs render, persistence, or solve settlement inside the active drag callback');
assert(functionSource('finalizeAtGate').includes('usesLightweightDragOverlay(b)')&&functionSource('finalizeAtGate').includes('requestAnimationFrame(()=>gateConnectEffect'),'Large-board gate decoration still mutates the SVG inside the finishing interaction task');
assert(bind.includes("flushBoardPointerMove(b,e,()=>finishPointer(e,'settled'))")&&bind.includes('b.releaseDrain?.pointerId===e.pointerId'),'Pointer-up settlement does not wait for the bounded drain or lost capture can cancel an active drain');
assert(functionSource('runDeferredSave').includes("classList?.contains('is-interacting')"),'Ordinary persistence is not deferred during interactions');
assert(functionSource('applyWorldSignal').includes('await waitForInteractionSettle()')&&functionSource('pullCloudWorld').includes('await waitForInteractionSettle()'),'Cross-tab or cloud reconciliation can still run broad refresh work during a gesture');
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes('waitForInteractionSettle().then(deliver)'),'Puzzle-worker promise continuations can still run during an active gesture');
assert(functionSource('runDeferredSave').includes("interactionActive('persistence')"),'Ordinary persistence is not deferred during interactions');
assert(functionSource('pullCloudWorld').includes('await waitForInteractionSettle()')&&!app.includes('applyWorldSignal'),'Server reconciliation can run broad refresh work during a gesture or retired cross-tab reconciliation remains');
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes("waitForInteractionSettle(null,'worker').then(deliver)"),'Puzzle-worker promise continuations can still run during an active gesture');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET')&&!functionSource('ensureBoards').includes('makeStaticBoard(meta)'),'Visible puzzles can still be hidden behind a clicked-only summary LOD');
assert(functionSource('observeInteractionFrame').includes('workDuration>slowWorkThreshold')&&!functionSource('observeInteractionFrame').includes('elapsed>24'),'Quality fallback still mistakes frame spacing for callback overload');
assert(functionSource('refreshInteractionState').includes('interactionQualityDowngradePending'),'Quality changes are not deferred until the gesture ends');
assert(!app.includes('interactionQualityDowngradePending')&&!app.includes('autoReducedEffects')&&functionSource('applyUiSettings').includes("classList.toggle('reduced-effects',uiSettings.lightweightRendering)"),'Interaction still activates an automatic lightweight visual mode');
assert(functionSource('refreshInteractionState').includes("topbar?.classList.toggle('drawing-active'")&&functionSource('refreshInteractionState').includes("world?.classList.toggle('camera-interacting'")&&functionSource('refreshInteractionState').includes('!usesLightweightDragOverlay(drawingBoard)')&&!css.includes('body.is-interacting .gate-dot')&&!css.includes('body.is-drawing #topbar'),'Pickup styling still invalidates the entire document or a lightweight large-board subtree');
assert(functionSource('recordInteractionCommit').includes('InputAge')&&functionSource('observeInteractionFrame').includes('interactionDroppedFrameRatio')&&app.includes("perfCount('longTasks')"),'Interaction latency, dropped-frame, or long-task diagnostics are missing');
assert(functionSource('ensureBoards').includes("classList?.contains?.('is-interacting')")&&functionSource('ensureBoards').includes('lodPassesDeferredDuringInteraction'),'Detailed and overview LOD work is not deferred during gestures');
assert(functionSource('ensureBoards').includes("interactionActive('world')")&&functionSource('ensureBoards').includes('lodPassesDeferredDuringInteraction'),'Detailed and overview LOD work is not deferred during gestures');
assert(functionSource('drawingForPath').includes('pathCellIndex:new Map')&&functionSource('extendOne').includes('pathCellIndex?.set'),'Long pickup paths do not retain a cell index');
assert(functionSource('rewindActivePathToCell').includes('index===path.cells.length-1)return false'),'A pickup hovering on its current tip is incorrectly classified as a geometry-changing rewind');
@ -118,6 +90,21 @@ const validLongCells=new Set(longCells.map(([r,c])=>`${r},${c}`)),forward=traver
assert(forward.length===249&&forward[248][1]===249,'A 250-cell pointer segment does not preserve every forward grid cell');
let remainingCatchup=forward.length,batches=0,maxBatch=0;while(remainingCatchup){const batch=Math.min(24,remainingCatchup);maxBatch=Math.max(maxBatch,batch);remainingCatchup-=batch;batches++}
assert(maxBatch===24&&batches===11,'A 250-cell catch-up is not bounded to constant-size frame batches');
{
let progress=0,extendCalls=0,completed=0,scheduled=0;
const releaseContext={
DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME:4,DRAG_MODEL_BUDGET_MS:.5,DRAG_MAX_RELEASE_CATCHUP_CELLS:24,
perfStart:()=>0,perfNow:()=>0,perfEnd:()=>0,pointerInsideBoardScreen:()=>true,eventToSvg:(_board,move)=>[move.clientX,move.clientY],cellAt:(_board,point)=>[0,point[0]],
extendPointerTo:board=>{extendCalls++;progress+=Math.min(24,250-progress);board.drawing.catchupPending=progress<250},
usesLightweightDragOverlay:()=>false,renderDragFrame:()=>{},recordInteractionCommit:()=>{},perfCount:()=>{},
scheduleBoardPointerReleaseDrain:()=>{scheduled++;return true},ensureBoardDragScheduler:()=>({beginSettling:()=>true}),queueMicrotask:callback=>callback()
};
vm.createContext(releaseContext);vm.runInContext(`${releaseDrain}\nthis.processBoardPointerReleaseDrain=processBoardPointerReleaseDrain;`,releaseContext);
const drain={pointerId:1,moves:[{pointerId:1,clientX:250,clientY:0,inputAt:1}],index:0,inputAt:1,tracedIndexes:new Set(),onComplete:()=>completed++},
board={drawing:{pointerId:1,catchupPending:false},lastFlushPointerCells:[],logicalPointerCellTrace:[],releaseDrain:drain};
while(board.releaseDrain){const before=extendCalls;releaseContext.processBoardPointerReleaseDrain(board,drain,extendCalls*17);assert(extendCalls-before<=4,'A release frame exceeded its logical-sample work bound')}
assert(progress===250&&extendCalls===11&&completed===1&&scheduled===2,'Bounded release drain lost, duplicated, or incompletely settled the final 250-cell intent');
}
const endpointState={paths:Array.from({length:100},(_,index)=>({endGate:null,detachedStart:index%2===0,cells:[[0,index],[1,index]]}))},
endpointContext={Map,ckey:(r,c)=>`${r},${c}`,metaState:()=>endpointState};

View file

@ -0,0 +1,23 @@
'use strict';
const {assert,functionSource,app,css,html,read,vm,buildMeta}=require('./helpers/app-source');
const packageVersion=JSON.parse(read('package.json')).version;
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
assert(html.includes('id="resetSettings"')&&html.includes('id="closeSettings"')&&!html.includes('id="saveSettings"'),'Settings actions must expose Initial reset and Close without a Save button');
assert(functionSource('closeSettings').includes('saveSettings(restoreFocus)')&&functionSource('saveSettings').includes('closeSettingsDialog(restoreFocus)')&&functionSource('resetSettingsForm').includes('lightweightRenderingToggle.checked=false'),'Close does not apply settings or Initial reset is incomplete');
assert(functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering',uiSettings.lightweightRendering)")&&functionSource('applyUiSettings').includes("classList.toggle('reduced-effects',uiSettings.lightweightRendering)")&&!app.includes('autoReducedEffects')&&!app.includes('interactionQualityDowngradePending'),'Panning/interaction still enables an automatic lightweight mode');
assert(!css.includes('#viewport.panning::after{display:none}')&&!css.includes('#world.camera-interacting')&&css.includes('body.lightweight-rendering #noiseCanvas'),'Pan-only lightweight CSS remains or the explicit setting was removed');
assert(html.includes('id="boardHudLayer"')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)')&&functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&css.includes('.board-label[hidden]{display:none!important}'),'Active board HUD is still board-clipped or not viewport-clamped');
assert(!functionSource('hudPlacementCandidates').includes('occupancy.get'),'HUD placement still rejects overlap with neighboring puzzles');
assert(functionSource('positionCachedWorldOverview').includes('cam.scale/Math.max')&&functionSource('positionCachedWorldOverview').includes('translate3d')&&functionSource('positionCachedWorldOverview').includes('scale(${ratio})')&&functionSource('applyCamera').includes('positionCachedWorldOverview()'),'Zoomed-out overview does not move/scale its cached camera layer during pan');
assert(functionSource('updateDrawingHandlePosition').includes('point[0],point[1]')&&functionSource('processBoardDragFrame').includes('else if(b.drawing?.pointerId===move.pointerId)renderDragFrame(b)'),'Dragged knob is not tied directly to the latest pointer position');
assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&!functionSource('updateCustomCursorFromPointer').includes('customEmojiCursor.style.transform')&&functionSource('commitCustomCursorFrame').includes('customEmojiCursor.style.transform=`translate3d(')&&css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Custom cursor coverage, capped tracking, or drag-time hiding regressed');
const overviewContext={overviewCanvas:{hidden:true,style:{}},overviewCache:{scale:.2,baseWidth:1000,baseHeight:800,anchorX:0,anchorY:0,unit:43},cam:{scale:.1},MIN_CAMERA_SCALE:.01,currentCenter:[2,3],getViewportRect:()=>({width:800,height:600}),inWorldOverview:()=>true,perfCount:()=>{}};
overviewContext.cameraCenterInChunks=()=>overviewContext.currentCenter;vm.createContext(overviewContext);vm.runInContext(`${functionSource('positionCachedWorldOverview')}this.positionCachedWorldOverview=positionCachedWorldOverview;`,overviewContext);
overviewContext.positionCachedWorldOverview();const overviewTransformBefore=overviewContext.overviewCanvas.style.transform;overviewContext.currentCenter=[5,-1];overviewContext.positionCachedWorldOverview();
assert(overviewTransformBefore!==overviewContext.overviewCanvas.style.transform&&overviewTransformBefore.includes('scale(0.5)'),'Cached overview transform does not react to camera movement and scale');
const hudStyle={removeProperty(name){delete this[name]}},hudLabel={hidden:true,classList:{add(){}},style:hudStyle,offsetWidth:220,offsetHeight:44,dataset:{}},hudContext={boardHudLayer:{},MIN_CAMERA_SCALE:.01,cam:{scale:.6},CELL:43,PAD:26,UNIT:215,boardPlayHudVisible:()=>true,hudPlacementCandidates:()=>[{side:'N',dx:0,dy:0,priority:0}],getViewportRect:()=>({left:0,top:0,width:640,height:360}),boardScreenRect:()=>({left:140,top:-120,width:300,height:250})};
vm.createContext(hudContext);vm.runInContext(`${functionSource('positionBoardLabel')}this.positionBoardLabel=positionBoardLabel;`,hudContext);const hudBoard={label:hudLabel,meta:{},p:{bounds:{w:5,h:5}}};
assert(hudContext.positionBoardLabel(hudBoard)&&parseFloat(hudLabel.style.top)>=8&&parseFloat(hudLabel.style.top)<=308,'Detached HUD is not clamped inside the visible viewport');
console.log('v47.84 settings, pan rendering, detached HUD, cursor, and knob regression test passed');

Some files were not shown because too many files have changed in this diff Show more