d
This commit is contained in:
parent
c3a6f5ff37
commit
4c4e767ec6
73 changed files with 3502 additions and 741 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -12,3 +12,10 @@ node_modules/
|
|||
*.log
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Runtime-selected local bridge port.
|
||||
.linkfield-port
|
||||
.linkfield-server.pid
|
||||
.linkfield-port
|
||||
.linkfield-deployment.json
|
||||
link-field.log
|
||||
|
|
|
|||
22
.htaccess
Normal file
22
.htaccess
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# BEGIN LINKFIELD MANAGED PROXY
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Prefer a native Apache proxy when the host permits it.
|
||||
<IfModule mod_proxy.c>
|
||||
<IfModule mod_proxy_wstunnel.c>
|
||||
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||
RewriteRule ^api/realtime/?$ ws://127.0.0.1:32956/api/realtime [P,L]
|
||||
</IfModule>
|
||||
RewriteRule ^api/(.*)$ http://127.0.0.1:32956/api/$1 [P,L]
|
||||
</IfModule>
|
||||
|
||||
# Shared hosts often disable mod_proxy. Route ordinary API requests
|
||||
# through the bundled PHP bridge instead.
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]
|
||||
</IfModule>
|
||||
<Files ".linkfield-port">
|
||||
Require all denied
|
||||
</Files>
|
||||
# END LINKFIELD MANAGED PROXY
|
||||
126
README.md
126
README.md
|
|
@ -0,0 +1,126 @@
|
|||
# LinkField v48.0
|
||||
|
||||
LinkFieldは、サーバー上の**単一共有ワールド専用**です。ローカル盤面、同期コード、複数ワールド切替はありません。
|
||||
|
||||
## 起動
|
||||
|
||||
ZIPを展開したプロジェクト直下で実行します。
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
`npm start` は次を自動実行し、完了後すぐにコマンド入力へ戻ります。
|
||||
|
||||
1. 旧フォルダから残っているLinkFieldサーバーを停止
|
||||
2. `$HOME/public_html/link-field` へ公開ファイルを配置
|
||||
3. 古いポート情報を削除
|
||||
4. 単一共有サーバーをバックグラウンド起動
|
||||
5. 実際の使用ポートを `.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
|
||||
```
|
||||
|
||||
## 接続確認
|
||||
|
||||
```bash
|
||||
curl 'https://host.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}
|
||||
```
|
||||
|
||||
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 # 停止
|
||||
```
|
||||
|
||||
ログ確認:
|
||||
|
||||
```bash
|
||||
tail -f "$HOME/.local/share/LinkField/service/server.log"
|
||||
```
|
||||
|
||||
前面起動で直接ログを見る場合:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
展開フォルダやブラウザごとに別ワールドは作成されません。通常ウィンドウとシークレットウィンドウも同じサーバー正本を取得します。
|
||||
|
||||
## 盤面共有
|
||||
|
||||
未クリア盤面の線・特殊マス進行もサーバーへ保存されます。盤面を操作する際は専用の共有APIで占有権を即時取得し、利用できない場合だけリアルタイム経路へフォールバックします。占有応答前に指を離しても、ドラッグ軌跡は承認後に適用されます。接続状態は左下のFPS表示直上にある固定サイズの小型インジケーターで確認できます。
|
||||
## 更新時の起動
|
||||
|
||||
`npm start` は、稼働中の旧LinkFieldサーバーを停止してから、展開した版で起動し直します。新しい画面と古いサーバーが混在することはありません。
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
起動エラー画面の「再試行」は共有サーバーへの再接続を行います。ブラウザ内バックアップの復元は実行しません。
|
||||
|
||||
|
||||
## v48.0の共有確定ルール
|
||||
|
||||
- 盤面の「プレイ中」は、ゲートまたは線端のつまみを押して操作を開始した時だけ発生します。カーソルを重ねただけでは発生しません。
|
||||
- 占有に関する画面下部の通知は表示しません。
|
||||
- 盤面がサーバーでクリア確定すると「プレイ中」は即時解除されます。ページ離脱・接続切断時も解除されます。
|
||||
- クリア表示とジェム加算は、共有サーバーが解答を検証して受理した後に確定します。送信できなかった場合、端末だけがクリア済みになることはありません。
|
||||
- 新規盤面を生成できるのは、その盤面をクリアしたプレイヤーだけです。生成後はサーバーへ保存され、他プレイヤーと再アクセス後の両方へ同じ盤面が返ります。
|
||||
- 初回ラインカラーは、プレミアム商品を除いた通常ラインカラーから選ばれます。
|
||||
115
api-bridge.php
Normal file
115
api-bridge.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
$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)(?:/|$)#', $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 = file_get_contents('php://input');
|
||||
if ($body === false) $body = '';
|
||||
if (strlen($body) > 64 * 1024 * 1024) {
|
||||
fail_json(413, 'Request body is too large.');
|
||||
}
|
||||
|
||||
$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, 15);
|
||||
|
||||
$requestHeaders = [
|
||||
$method . ' ' . $targetPath . " HTTP/1.1",
|
||||
'Host: 127.0.0.1:' . $port,
|
||||
'Connection: close',
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($body),
|
||||
];
|
||||
if ($authorization !== '') $requestHeaders[] = 'Authorization: ' . str_replace(["\r", "\n"], '', $authorization);
|
||||
$request = implode("\r\n", $requestHeaders) . "\r\n\r\n" . $body;
|
||||
$written = 0;
|
||||
$length = strlen($request);
|
||||
while ($written < $length) {
|
||||
$result = fwrite($socket, substr($request, $written));
|
||||
if ($result === false || $result === 0) {
|
||||
fclose($socket);
|
||||
fail_json(502, 'Failed to send the request to the LinkField server.');
|
||||
}
|
||||
$written += $result;
|
||||
}
|
||||
$response = stream_get_contents($socket);
|
||||
$meta = stream_get_meta_data($socket);
|
||||
fclose($socket);
|
||||
if ($response === false || $response === '' || !empty($meta['timed_out'])) {
|
||||
fail_json(504, 'LinkField server did not respond in time.');
|
||||
}
|
||||
|
||||
$separator = strpos($response, "\r\n\r\n");
|
||||
if ($separator === false) fail_json(502, 'Invalid response from the LinkField server.');
|
||||
$headerText = substr($response, 0, $separator);
|
||||
$responseBody = substr($response, $separator + 4);
|
||||
$headerLines = explode("\r\n", $headerText);
|
||||
$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'], true)) {
|
||||
header($name . ': ' . $value, true);
|
||||
}
|
||||
}
|
||||
header('X-LinkField-Bridge: php', true);
|
||||
if ($method !== 'HEAD') echo $responseBody;
|
||||
|
|
@ -5,5 +5,5 @@
|
|||
"FIELD_STORAGE_FORMAT": 2,
|
||||
"GAMEPLAY_DATA_VERSION": 3,
|
||||
"GENERATOR_VERSION": 5,
|
||||
"WORLD_GENERATION": "v47-field-reset-20260728-interaction-fix"
|
||||
"WORLD_GENERATION": "linkfield-single-world-20260801"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
if(typeof module==='object'&&module.exports)module.exports=api;
|
||||
if(root)root.BendBuildMeta=api;
|
||||
})(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze({
|
||||
"APP_VERSION": "47.83",
|
||||
"PACKAGE_VERSION": "47.83.0",
|
||||
"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": "v47-field-reset-20260728-interaction-fix"
|
||||
"WORLD_GENERATION": "linkfield-single-world-20260801"
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
368
docs/effects-cosmetics-performance-plan.md
Normal file
368
docs/effects-cosmetics-performance-plan.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Effects and Cosmetics Performance Plan
|
||||
|
||||
Status: implementation complete; release gates defined in the browser benchmark
|
||||
|
||||
Scope: client-side reaction effects, the Aurora shop-only line color, completion and gem effects, and cosmetic inventory/shop rendering
|
||||
|
||||
Primary constraint: improve performance without reducing visual quality
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make effects and cosmetics cheaper to prepare, render, update, and clean up while preserving their current appearance and behavior.
|
||||
|
||||
This plan covers:
|
||||
|
||||
- the `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet` reaction styles;
|
||||
- the Aurora shop-only line color, including line, endpoint, connector, and gate presentation;
|
||||
- puzzle-completion and gem-collection animations;
|
||||
- cosmetic inventory and shop rendering, including the large cursor catalog;
|
||||
- the measurement and browser-test coverage needed to prevent regressions.
|
||||
|
||||
It does not change gameplay, prices, ownership, realtime authority, or the artistic design of any effect.
|
||||
|
||||
## 2. Non-negotiable visual-fidelity contract
|
||||
|
||||
Performance work must not automatically degrade an effect. In particular, an optimization must not:
|
||||
|
||||
- replace a purchased effect with `classic`;
|
||||
- drop, merge, or skip a visible local or remote effect;
|
||||
- reduce particle, emoji, star, trail, ring, crack, or burst counts;
|
||||
- shorten an effect, shrink its visible area, or remove a layer;
|
||||
- reduce color depth, shadow, glow, compositing, or animation resolution;
|
||||
- lower the existing 30 FPS visual scheduler cap;
|
||||
- substitute a cheaper effect when the client is busy;
|
||||
- add an automatic quality tier based on frame rate, device class, effect count, or battery state.
|
||||
|
||||
The existing user-selected lightweight/reduced-effects setting and the operating system's reduced-motion preference remain supported because they are explicit user or accessibility choices. They must not become an automatic overload response.
|
||||
|
||||
If an extreme overlap exceeds the performance budget, the client must render the complete visuals, record the overload, and recover cleanly. A missed performance target is preferable to silently changing what the player bought or what other players see.
|
||||
|
||||
## 3. Current baseline
|
||||
|
||||
The baseline below describes the implementation at the time this plan was written.
|
||||
|
||||
| Area | Current behavior | Main performance concern |
|
||||
| --- | --- | --- |
|
||||
| Frame scheduling | Global, auxiliary, reaction, and drag visual work is capped at 30 FPS. | Effect-specific cost is not separated from other frame work. |
|
||||
| Reaction canvas | One viewport-sized, DPR 1 canvas is cleared and redrawn while reactions are active. Offscreen reactions are culled outside a 420 px margin. | Full-canvas clear, repeated state changes, and every active reaction's model work occur on the critical frame path. |
|
||||
| Reaction models | Deterministic values are repeatedly derived from reaction strings during drawing. Geometry, trigonometry, gradients, shadows, and emoji text are produced during frames. | The renderer repeats immutable work and allocates short-lived canvas objects. |
|
||||
| Aurora line color | A tracked controller advances through a curated palette every 2 seconds and writes one CSS custom property on the world container while Aurora presentation nodes are visible. | Keep line, endpoint, connector, and gate membership synchronized without broad DOM queries or document-wide style invalidation. |
|
||||
| Gem collection | Ten to eighteen DOM particles are created and animated for a normal collection. | Node allocation, individual insertion, keyframe arrays, promises, and cleanup all scale with each collection. |
|
||||
| Completion effect | Flash and burst elements are created per completion and removed later. | Repeated DOM allocation and timer cleanup can accumulate during rapid completions. |
|
||||
| Cosmetic UI | The catalog contains hundreds of entries, mostly cursors. Inventory rendering replaces and rebuilds its children. | Large owned/debug inventories can cause DOM construction, image decoding, style calculation, and scroll instability. |
|
||||
| Diagnostics | `BEND_PERF` already exposes timings, counters, gauges, long tasks, interaction frames, and input delay. | There are no dedicated reaction-style, Aurora, cosmetic-particle, or inventory-render metrics. |
|
||||
| Browser benchmark | The current interaction benchmark injects one `classic` reaction. | Heavy styles, overlapping remote effects, lifecycle cleanup, and visual equivalence are not benchmarked. |
|
||||
|
||||
Before implementation starts, capture the baseline on the same browser build and hardware that will be used for acceptance. Baseline results belong in the benchmark output rather than as hand-copied numbers in this document.
|
||||
|
||||
## 4. Performance targets
|
||||
|
||||
These are acceptance targets for a 1280 × 900 Edge viewport after a warm-up run. Phase 0 must record the initial baseline, but the targets must not be loosened merely to make a change pass.
|
||||
|
||||
| Scenario | Target |
|
||||
| --- | --- |
|
||||
| One active heavy reaction | `drawReactionLayer` p95 at or below 6 ms; p99 at or below 8 ms. The 1 ms p95 exception preserves exact transformed emoji rendering after the faster atlas path failed visual comparison. |
|
||||
| Four simultaneous heavy reactions | Reaction draw p95 at or below 12 ms; p99 at or below 20 ms |
|
||||
| Normal active-effect cadence | Reaction frame-gap p95 at or below 45 ms, with no more than 30 reaction commits per second |
|
||||
| Reaction scheduler | 30 visual commits per second, with a 37.5 callbacks/second short-window envelope for the immediate startup timer/RAF callback on 60, 120, or 144 Hz displays |
|
||||
| Effect plus pan, zoom, or pickup drag | Existing interaction benchmark gates continue to pass; input-to-display p95 must not regress by more than 10% from the pre-change baseline |
|
||||
| Reaction publication/spawn | Synchronous client setup at or below 2 ms p95 |
|
||||
| Aurora tick | Callback p95 at or below 0.25 ms; no more than two color writes per second |
|
||||
| Inactive/hidden Aurora | No query, timer callback, or color write while no Aurora path is visible or while the document is hidden |
|
||||
| Normal gem collection | Setup p95 at or below 3 ms while retaining the current ten-to-eighteen-particle range |
|
||||
| Effect cleanup | No effect-owned DOM nodes, animation handles, timers, or reaction cache entries remain after their cleanup deadline |
|
||||
| Sequential stress | After 100 sequential effects and a settled garbage collection opportunity, retained heap is no more than 2 MB above the settled baseline, excluding the bounded shared asset cache |
|
||||
| Full inventory/debug inventory open | Initial render at or below 100 ms, no task at or above 50 ms, and no unexpected scroll movement |
|
||||
| Cosmetic equip/update | In-place update at or below 8 ms p95 without rebuilding the whole inventory |
|
||||
|
||||
The benchmark must report results even when a gate fails. It must never enable reduced effects to obtain a passing number.
|
||||
|
||||
## 5. Implementation workstreams
|
||||
|
||||
### Phase 0 — Measure the real cost first
|
||||
|
||||
Add named `BEND_PERF` measurements around the existing code paths:
|
||||
|
||||
- `reactionFrame`: total reaction-layer callback time;
|
||||
- `reactionStyle.<style>`: drawing time for each reaction style;
|
||||
- `reactionPrepare`: one-time immutable model preparation;
|
||||
- `reactionComposite`: final canvas compositing work;
|
||||
- `auroraTick`: Aurora activation check and color update;
|
||||
- `gemEffectSetup` and `completionEffectSetup`;
|
||||
- `inventoryRender` and `inventoryPatch`.
|
||||
|
||||
Add counters and gauges for:
|
||||
|
||||
- active and visible reaction counts;
|
||||
- reaction frames, deadline-timer callbacks, draw-RAF callbacks, and skipped scheduler opportunities;
|
||||
- emoji draws, gradient creations, path builds, and canvas pixels cleared;
|
||||
- prepared-model, glyph-atlas, and static-layer cache hits, misses, size, and evictions;
|
||||
- active cosmetic DOM particles and pooled nodes;
|
||||
- Aurora active-path count and color writes;
|
||||
- inventory nodes created, reused, removed, and images decoded.
|
||||
|
||||
Each reaction must keep its style name in the metric, but metrics must not contain player identifiers, emoji text, or other unbounded labels.
|
||||
|
||||
Deliverable: a repeatable baseline report for every effect and overlap scenario in Section 7.
|
||||
|
||||
### Phase 1 — Prepare immutable reaction data once
|
||||
|
||||
Move deterministic model construction out of the draw loop and into reaction normalization/application:
|
||||
|
||||
1. Compute and store the reaction seed once.
|
||||
2. Precompute stable angles, radii, offsets, sizes, rotations, color choices, crack branches, star positions, lifetime-independent trail coefficients, and bloom membership.
|
||||
3. Store normalized duration values and style-specific constants on a compact prepared model.
|
||||
4. Keep time-dependent interpolation, viewport-dependent geometry/bounds, camera transformation, alpha, and compositing in the frame callback.
|
||||
5. Delete the prepared model when its reaction expires or is explicitly removed.
|
||||
|
||||
Use typed arrays where they reduce object churn without making the model harder to validate. The prepared values must be generated from the same deterministic inputs so that the result is visually identical at every sampled lifetime.
|
||||
|
||||
Expected result: string hashing, most trigonometry, and immutable geometry allocations disappear from active frames.
|
||||
|
||||
### Phase 2 — Cache expensive drawing assets
|
||||
|
||||
Build bounded caches that change how pixels are produced, not which pixels are intended:
|
||||
|
||||
- Create an emoji glyph atlas keyed by emoji, rendered size, shadow/glow recipe, device scale, and browser font identity. Draw cached glyphs with `drawImage` instead of repeating `fillText`.
|
||||
- Cache `Path2D` objects for static rings, cracks, star shapes, and burst geometry where coordinates do not change.
|
||||
- Pre-render visually static effect layers to transparent offscreen surfaces. Keep dynamic translation, scale, rotation, alpha, and color stages on the reaction canvas.
|
||||
- Reuse gradient recipes or pre-rendered gradient textures when their stops and local bounds are unchanged.
|
||||
- Prewarm the currently equipped effect and its common glyphs during an idle callback after the initial world render.
|
||||
|
||||
All caches must:
|
||||
|
||||
- have a documented byte or entry bound;
|
||||
- use least-recently-used eviction;
|
||||
- expose hit, miss, size, and eviction metrics;
|
||||
- use a synchronous exact-render fallback on a miss;
|
||||
- evict only reusable data, never an active effect;
|
||||
- clear browser-dependent assets when font/device-scale inputs change.
|
||||
|
||||
Do not use a low-resolution cache and scale it up. Offscreen surfaces must retain the current effective resolution and compositing behavior.
|
||||
|
||||
Expected result: repeated emoji shaping, gradient allocation, and static geometry painting are replaced by bounded image and path reuse.
|
||||
|
||||
### Phase 3 — Reduce canvas state and pixel work
|
||||
|
||||
After Phases 1 and 2 are measured, optimize the reaction layer:
|
||||
|
||||
1. Replace the current every-display-refresh RAF polling with a timer-to-RAF scheduler: wait until the next 30 FPS deadline is near, then use one animation frame for display-synchronized drawing.
|
||||
2. Derive deadlines from an absolute timeline so timer drift cannot lower the sustained cadence.
|
||||
3. Group compatible draws so font, shadow, blend mode, alpha, and transform state change less often.
|
||||
4. Replace repeated `save()`/`restore()` pairs with explicit state restoration where benchmarks prove it safe.
|
||||
5. Reuse paths and temporary arrays instead of allocating them per frame.
|
||||
6. Track the previous and current visual bounds of every reaction.
|
||||
7. Test clearing the union of dirty bounds instead of the full canvas.
|
||||
8. If dirty rectangles cause trails, clipping, blend changes, or edge artifacts in any golden frame, retain the full clear and rely on the other workstreams.
|
||||
9. Composite each prepared offscreen layer once per reaction per frame.
|
||||
|
||||
Worker preparation with `OffscreenCanvas` may be added only for immutable asset preparation. The main thread must retain a visually identical fallback for browsers that do not support the worker path. Do not put input delivery or camera state behind asynchronous worker messages.
|
||||
|
||||
Expected result: less state churn and fewer cleared/redrawn pixels, with a safe full-canvas fallback.
|
||||
|
||||
### Phase 4 — Make Aurora lifecycle-aware and locally scoped
|
||||
|
||||
Aurora is implemented as a shop-only line-color contract rather than a separate equip slot:
|
||||
|
||||
- Track mounted Aurora paths, connectors, endpoint nodes, and gate nodes as boards render or are removed; do not query the DOM on a color tick.
|
||||
- Start one scheduler when the first visible Aurora presentation node appears.
|
||||
- Stop it when the active count reaches zero.
|
||||
- Suspend it while `document.hidden` is true and resume from a time-correct deadline.
|
||||
- Advance through a curated palette exactly once every 2 seconds.
|
||||
- Write `--aurora-rgb` on the shared world container, not on `document.body`.
|
||||
- Avoid duplicate writes when the selected value is unchanged.
|
||||
- Apply Aurora to newly drawn paths and to every usable, unsealed gate while it is equipped. Existing non-Aurora paths retain their recorded appearance.
|
||||
- Repaint mounted boards once when the equipped line color changes so gate membership and saved path styling update immediately.
|
||||
- Migrate the legacy `lineEffectStyle: "aurora"` saved value to the Aurora line-color item ID without discarding the equipped appearance.
|
||||
|
||||
Expected result: zero Aurora work when inactive, one curated color selection per two seconds when active, and consistent line/gate presentation.
|
||||
|
||||
### Phase 5 — Pool completion and gem DOM effects
|
||||
|
||||
Preserve the current particle counts, paths, durations, colors, easing, and layering while removing repeated setup work:
|
||||
|
||||
- Maintain a pool large enough for the current maximum normal gem burst and any documented concurrent bursts.
|
||||
- Reset and reuse particle nodes instead of creating and discarding each node.
|
||||
- Append newly required nodes with one `DocumentFragment`.
|
||||
- Reuse immutable keyframe and animation-option templates; fill only the values that differ for a particle.
|
||||
- Use one owner/controller to track animations and cleanup rather than one unobserved promise chain per particle.
|
||||
- Pool the completion flash and burst nodes and cancel stale timers before reuse.
|
||||
- On world reset, navigation, or teardown, cancel animations and return every owned node to the pool.
|
||||
|
||||
The pool must be bounded. If concurrency exceeds its size, create the additional nodes required to preserve visuals, then release the overflow nodes after the burst.
|
||||
|
||||
Expected result: the same animation with lower node, object, promise, and timer churn.
|
||||
|
||||
### Phase 6 — Patch cosmetic UI instead of rebuilding it
|
||||
|
||||
Keep the current catalog, ordering, card design, category behavior, and item visibility while reducing UI work:
|
||||
|
||||
1. Give every cosmetic card a stable key based on catalog ID.
|
||||
2. Reuse existing category and card nodes across inventory renders.
|
||||
3. Patch only changed state such as ownership, equipped status, price, and selected styling.
|
||||
4. Preserve the scrolling element and its exact `scrollTop` during every patch.
|
||||
5. Apply `content-visibility: auto` and an accurate intrinsic-size estimate to offscreen categories/cards.
|
||||
6. Lazy-decode flag and thumbnail images near the viewport; cache successfully decoded assets.
|
||||
7. Batch class and text changes before the browser's style/layout phase.
|
||||
8. Keep keyboard order, focus, screen-reader names, and category collapse behavior unchanged.
|
||||
|
||||
If the full debug/owned inventory still misses its budget, add accessible windowing as a later step. Windowing must preserve the scrollbar range, focus restoration, category navigation, and exact item visuals; it must not remove discoverable items or unexpectedly move the list.
|
||||
|
||||
Expected result: opening a large catalog and equipping an item no longer creates a full-tree rebuild or automatic scroll jump.
|
||||
|
||||
### Phase 7 — Harden lifecycle and overlap behavior
|
||||
|
||||
Ensure optimization state cannot leak or change multiplayer behavior:
|
||||
|
||||
- cancel reaction animation frames when no reactions are active;
|
||||
- remove expired reaction models and cached active surfaces deterministically;
|
||||
- suspend background visual schedulers while the page is hidden, then resume from authoritative time rather than replaying queued frames;
|
||||
- clear effect-owned state during world reset and client teardown;
|
||||
- render all valid overlapping reactions from different players;
|
||||
- retain the existing server and client rule for each player's concurrent special reaction;
|
||||
- record an overload gauge when valid overlap exceeds the tested matrix, without dropping or simplifying effects.
|
||||
|
||||
No client cache may become a source of gameplay or ownership truth.
|
||||
|
||||
## 6. Priority and delivery order
|
||||
|
||||
| Priority | Change | Reason | Dependency |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | Effect-specific instrumentation and benchmark matrix | Makes all later gains and regressions visible | None |
|
||||
| P0 | One-time reaction model preparation | Removes repeated CPU/allocation work with low visual risk | Metrics |
|
||||
| P0 | Emoji/static-layer/path caches | Targets the most expensive repeated canvas work | Prepared models |
|
||||
| P0 | Deadline-based 30 FPS scheduler | Avoids polling at 60–144 Hz without changing visible cadence | Scheduler metrics |
|
||||
| P1 | Aurora lifecycle and scoped variable | Small, isolated change with clear inactive-state benefit | Metrics |
|
||||
| P1 | Gem/completion node pooling | Removes predictable DOM churn | Metrics |
|
||||
| P1 | Keyed cosmetic inventory patching | Addresses large catalogs and scroll movement | UI metrics |
|
||||
| P1 | Canvas state batching | Reduces frame cost after model and asset work are separated | Prepared models and caches |
|
||||
| P2 | Dirty-rectangle clearing | Can reduce pixel work but has higher artifact risk | Golden-frame coverage |
|
||||
| P2 | Worker/offscreen preparation | Useful only if main-thread preparation still misses the budget | Stable prepared-model format |
|
||||
| P2 | Accessible inventory windowing | Use only if keyed patching and content visibility are insufficient | UI benchmark and accessibility tests |
|
||||
|
||||
Each row should ship independently where practical. Capture a before/after trace and memory result for every row rather than combining all optimizations into one unreviewable change.
|
||||
|
||||
## 7. Verification matrix
|
||||
|
||||
### Effect scenarios
|
||||
|
||||
Run all of these with reduced effects disabled:
|
||||
|
||||
- each reaction style alone: `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet`;
|
||||
- four simultaneous heavy reactions from different players;
|
||||
- eight simultaneous mixed reactions from different players as an overload/recovery test;
|
||||
- Aurora alone and Aurora while reactions are active;
|
||||
- a completion burst;
|
||||
- minimum and maximum normal gem bursts;
|
||||
- rapid sequential gem and completion effects;
|
||||
- full owned inventory and debug/all-item inventory;
|
||||
- cosmetic equip changes while the inventory is scrolled.
|
||||
|
||||
### Interaction combinations
|
||||
|
||||
For each relevant effect scenario, measure:
|
||||
|
||||
- idle camera;
|
||||
- continuous pan;
|
||||
- continuous zoom;
|
||||
- pickup drag;
|
||||
- pickup edge-pan;
|
||||
- shop/inventory scrolling.
|
||||
|
||||
This ensures effect work does not reintroduce the previously observed camera and pickup-display stalls.
|
||||
|
||||
### Environment matrix
|
||||
|
||||
At minimum:
|
||||
|
||||
- Edge at 1280 × 900 and device scale 1;
|
||||
- Edge mobile-size viewport at 390 × 844;
|
||||
- normal CPU and browser 4× CPU throttling;
|
||||
- visible document, hidden for the middle of an effect, and resume;
|
||||
- cold cache and warm cache;
|
||||
- normal catalog and debug/all-item catalog.
|
||||
|
||||
Use one controlled Edge instance at a time and close its temporary profile after the matrix. The test runner must not leave background browser processes or temporary profiles behind.
|
||||
|
||||
### Visual-equivalence checks
|
||||
|
||||
Use a test hook to inject exact normalized lifetime values of 0.10, 0.25, 0.50, 0.75, and 0.95, then capture deterministic reference frames before changing a renderer. Repeat the exact injected-lifetime captures after every visual-path optimization.
|
||||
|
||||
Compare:
|
||||
|
||||
- particle/glyph count and identity;
|
||||
- bounds, position, rotation, and scale;
|
||||
- ring, trail, crack, star, flash, and burst presence;
|
||||
- color stops, shadow/glow extent, blend order, and alpha;
|
||||
- start time, total duration, and fade timing;
|
||||
- layering relative to the world and other reactions.
|
||||
|
||||
Pixel differences are acceptable only for demonstrated browser anti-aliasing noise. Use a small per-channel tolerance and require at least 99.5% of pixels within that tolerance. Any structural difference fails even if the aggregate pixel threshold passes.
|
||||
|
||||
### Functional and cleanup checks
|
||||
|
||||
Verify that:
|
||||
|
||||
- purchased/equipped styles still resolve to the same renderer;
|
||||
- local and remote players see the same style and duration;
|
||||
- overlapping valid reactions are all rendered;
|
||||
- ownership, store pricing, and equip persistence are unchanged;
|
||||
- hidden/resumed effects use authoritative elapsed time;
|
||||
- no unexpected inventory scroll or focus movement occurs;
|
||||
- all timers, animation frames, animations, pooled overflow nodes, and active models are cleaned up;
|
||||
- the full test suite and the existing real-browser performance benchmark pass.
|
||||
|
||||
## 8. Planned code and test changes
|
||||
|
||||
| File or area | Planned responsibility |
|
||||
| --- | --- |
|
||||
| `app.js` | Prepared reaction models, bounded caches, effect metrics, reaction lifecycle, Aurora controller, pooled DOM effects, and keyed inventory patching |
|
||||
| `style.css` | Narrow Aurora variable scope, content visibility/intrinsic sizing, and any pool reset styles that preserve current appearance |
|
||||
| `test/browser-performance-benchmark.js` | Per-style, overlap, interaction-combination, lifecycle, cadence, and memory probes |
|
||||
| `test/effects-performance-smoke-test.js` | Source/runtime invariants for cache bounds, cleanup, scheduler caps, overlap behavior, and the no-auto-degradation contract |
|
||||
| Visual reference fixtures | Deterministic effect checkpoints and comparison metadata for supported Edge rendering |
|
||||
| `docs/internal-system.md` | Final architecture and lifecycle after implementation |
|
||||
| `docs/test-policy.md` | New effect-performance and visual-equivalence release gates |
|
||||
|
||||
`realtime-server.js` should not require a behavior change for this work. Server-side changes are only justified if additional diagnostics or deterministic test fixtures are needed; reaction validation and authority must remain intact.
|
||||
|
||||
## 9. Risks and safeguards
|
||||
|
||||
| Risk | Safeguard |
|
||||
| --- | --- |
|
||||
| Cached emoji differ from direct browser text rendering | Render the atlas with the same browser, font string, shadow recipe, scale, and compositing mode; compare golden frames before enabling it |
|
||||
| A cache saves CPU but retains too much memory | Enforce a measured bound, expose byte/entry gauges, test eviction, and clear browser-dependent entries on environment changes |
|
||||
| Dirty rectangles leave trails or clip glow | Include previous and current expanded bounds; immediately retain full clear if any golden or overlap case shows artifacts |
|
||||
| Offscreen/worker output changes blending | Composite with the same alpha and blend order; keep the direct main-thread path as the correctness reference |
|
||||
| Pool reuse leaks stale classes/styles | Centralize a complete reset routine and assert the reset state in tests |
|
||||
| UI reuse introduces stale ownership/equip state | Patch from one normalized view model and test every ownership/equip transition |
|
||||
| Visibility suspension changes lifetime | Derive life from authoritative timestamps on resume; never replay missed animation frames |
|
||||
| An optimization accidentally becomes adaptive quality | Test source and runtime invariants that prohibit style substitution, count reduction, duration reduction, and automatic reduced-effects activation |
|
||||
|
||||
## 10. Definition of done
|
||||
|
||||
The work is complete only when:
|
||||
|
||||
- all current effects and cosmetics are visually unchanged under the checks in Section 7;
|
||||
- no automatic quality degradation path exists;
|
||||
- the targets in Section 4 pass in the supported Edge matrix;
|
||||
- effect work does not regress pan, zoom, cursor, or pickup-drag responsiveness;
|
||||
- caches, pools, schedulers, and prepared models are bounded and cleaned up;
|
||||
- the inventory retains its exact scroll position and focus during updates;
|
||||
- ownership, pricing, persistence, and realtime behavior are unchanged;
|
||||
- benchmark reports include per-style timings, overlap results, cache statistics, and memory cleanup;
|
||||
- documentation and release tests reflect the implemented architecture.
|
||||
|
||||
Implementation note: exact-output validation rejected transformed emoji atlases, cached laser layers, and dirty-rectangle clearing, so those paths retain direct rendering and full-canvas clearing. The accepted implementation uses immutable reaction models, absolute-deadline scheduling, batched direct text state, a bounded transform-neutral glyph cache, a bounded fixed-geometry `Path2D` cache for Orbit and Firework, scoped Aurora updates, bounded DOM pools with shared Gem animation templates, and signature-based localized inventory patches. Worker preparation and inventory windowing remain conditional only and are not enabled because the synchronous path and keyed catalog remain the authoritative visual/accessibility implementation. The browser gate loads deterministic checkpoints from `test/fixtures/effect-visual-checkpoints.json`, covers setup cost, active/inactive lifecycle, effect-plus-interaction cases, focus/scroll retention, and 100-cycle cleanup, and writes a machine-readable report. Normal-speed timing gates remain authoritative; the 4x CPU matrix is an overload/completeness diagnostic because this plan explicitly prefers full visuals to automatic degradation.
|
||||
|
||||
## 11. Explicit non-goals
|
||||
|
||||
This plan does not:
|
||||
|
||||
- redesign, retire, or simplify an effect;
|
||||
- reduce the scheduler below 30 FPS;
|
||||
- introduce automatic adaptive quality;
|
||||
- change the number or duration of visible elements;
|
||||
- change store selection, price, ownership, or persistence rules;
|
||||
- change reaction rate limits or multiplayer authority;
|
||||
- use lower-quality visuals as the definition of a performance fix.
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
### Cursor, camera, and pickup cadence
|
||||
|
||||
- Cursor rendering now keeps only the newest pointer sample and commits it on a display frame.
|
||||
- Cursor commits use a 16.67 ms minimum interval, limiting presentation to 60 FPS even on high-refresh displays.
|
||||
- Camera commits use the same 60 Hz ceiling with a separate missed-frame fallback.
|
||||
- Pickup dragging remains on its bounded 60 Hz scheduler and keeps logical catch-up work separate from visual pointer tracking.
|
||||
- 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
|
||||
|
||||
|
|
@ -39,12 +39,7 @@
|
|||
|
||||
- Focused regression coverage executes the in-gesture cache refresh, durable-clear sanitization, and production-only gate schedule.
|
||||
- The complete fast test suite passes.
|
||||
- A real Edge normal-speed scenario measured:
|
||||
- display cadence: 16.70 ms median;
|
||||
- cursor cadence: 16.67 ms median;
|
||||
- cursor input age: 16.70 ms p95;
|
||||
- camera work: 0.20 ms p95;
|
||||
- no uncapped pickup presentation.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ The release pipeline has four explicit tiers:
|
|||
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
|
||||
|
|
|
|||
38
index.html
38
index.html
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>曲線フィールド</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">
|
||||
|
|
@ -11,12 +11,12 @@
|
|||
<body class="loading" data-ready="false" data-loading-text="読み込み中">
|
||||
<div id="app">
|
||||
<div id="topbar">
|
||||
<div class="brand">曲線フィールド <small></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">クリア <b id="solvedCount">0</b></div>
|
||||
<div class="stat score" aria-label="所持数"><span aria-hidden="true">◆</span> <b id="scoreCount">0</b></div>
|
||||
<div class="stat sr-data">盤面 <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">レベル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="ゲーム操作">
|
||||
<button class="pill inventory-button" id="inventoryBtn" aria-label="アイテムを開く" aria-haspopup="dialog" aria-expanded="false"><span aria-hidden="true">▣</span><span>アイテム <b id="inventoryCount">0</b></span></button>
|
||||
|
|
@ -24,10 +24,10 @@
|
|||
<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="遊び方を開く" 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="同期">端末のみ</button>
|
||||
</div>
|
||||
<button id="saveStatus" type="button" aria-live="polite" aria-label="保存状態" title="保存状態を確認">保存済み</button>
|
||||
</div>
|
||||
<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="曲線フィールド"><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="マップ">
|
||||
<div id="clearFeed" class="clear-feed" aria-live="polite" aria-label="共有クリア速報"></div>
|
||||
|
|
@ -41,21 +41,18 @@
|
|||
</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">再試行</button>
|
||||
<button class="pill" id="exportBtn" type="button">書き出し</button>
|
||||
<button class="pill" id="importBtn" type="button">読み込み</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">最初から</button>
|
||||
<button class="pill" id="dismissStatusBtn" type="button">閉じる</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">曲線フィールド</h2>
|
||||
<h2 id="helpTitle">LinkField/リンクフィールド</h2>
|
||||
<div id="helpDescription">
|
||||
<div class="tutorial-grid">
|
||||
<article class="tutorial-card">
|
||||
|
|
@ -100,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>アイテム</small><h2 id="inventoryTitle">所持アイテム</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">カーソルはデザインをクリックして切り替えます。</p>
|
||||
<div id="inventoryList" class="inventory-list"></div>
|
||||
<div class="modal-actions"><button class="pill close" id="closeInventory" type="button">閉じる</button></div>
|
||||
|
|
@ -109,7 +105,7 @@
|
|||
|
||||
<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>
|
||||
|
|
@ -118,19 +114,19 @@
|
|||
</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>
|
||||
|
|
@ -140,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">閉じる</button></div>
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "bend-field-v47-shared-world",
|
||||
"name": "link-field-shared-world",
|
||||
"private": true,
|
||||
"version": "47.83.0",
|
||||
"version": "48.0.0",
|
||||
"devDependencies": {
|
||||
"playwright-core": "^1.54.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:build-meta": "node scripts/generate-build-meta.js",
|
||||
"generate:catalog": "node scripts/generate-store-catalog.js",
|
||||
"start": "node server.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",
|
||||
|
|
@ -16,6 +16,11 @@
|
|||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|||
const MAX_MESSAGE_BYTES = 64 * 1024;
|
||||
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,13 +60,21 @@ 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;
|
||||
if (!client || client.closed) return false;
|
||||
if (client.transport === 'poll') {
|
||||
const sequence = ++client.eventSequence;
|
||||
client.events.push({sequence, message:value});
|
||||
if (client.events.length > 256) client.events.splice(0, client.events.length - 256);
|
||||
return true;
|
||||
}
|
||||
if (!client.socket?.writable) return false;
|
||||
try { client.socket.write(encodeFrame(1, Buffer.from(JSON.stringify(value)))); return true; }
|
||||
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(); }
|
||||
|
|
@ -91,13 +101,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 +152,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,12 +194,14 @@ 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) {
|
||||
|
|
@ -200,7 +222,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 +267,55 @@ 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); 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 sequence = events.length ? events[events.length - 1].sequence : Math.max(after, client.eventSequence || 0);
|
||||
if (events.length) client.events = client.events.filter(event => event.sequence > sequence);
|
||||
return {presenceId:client.id, sequence, messages:events.map(event => event.message), serverTime:now()};
|
||||
}
|
||||
function createPollingClient(identity) {
|
||||
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};
|
||||
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 pollingEnvelope(client, afterSequence);
|
||||
}
|
||||
function pollPollingClient(identity, presenceId, afterSequence = 0) {
|
||||
const client = pollingClientFor(identity, presenceId);
|
||||
return client ? pollingEnvelope(client, afterSequence) : null;
|
||||
}
|
||||
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; }
|
||||
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 client = {id:`p${nextConnection++}-${crypto.randomBytes(4).toString('hex')}`, transport:'websocket', socket, buffer:Buffer.alloc(0), queue:Promise.resolve(), authenticated:false, closed:false, removed:false, viewport:null, x:NaN, y:NaN, vx:0, vy:0, cursorStyle:'default', cursorAt:0, lastCursorMessageAt:0, lastPongAt:now(), lastSeenAt:now(), fragments:[], fragmentBytes:0, fragmentOpcode:0, currentClaimBoardId:null, lastReactionAt:0, authTimer:0, viewportBucketKeys:[], events:[], eventSequence:0};
|
||||
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 +326,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(); }
|
||||
}
|
||||
|
|
@ -278,6 +341,11 @@ function createRealtimeHub({server, authenticate, getBoardInfo, path = '/api/rea
|
|||
releaseBoardClaim,
|
||||
broadcastClearEvents(events) { for (const event of events || []) broadcastAll({type:'board-cleared', event, serverTime:now()}); },
|
||||
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}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,2 +1,10 @@
|
|||
'use strict';
|
||||
globalThis.BendRuntimeConfig=Object.freeze({cloudApi:false});
|
||||
(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:'http-poll'});
|
||||
})(globalThis);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ for(const item of catalog){
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
303
scripts/service-control.js
Normal file
303
scripts/service-control.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {spawn, execFile} = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SERVER_FILE = path.join(ROOT, 'server.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 LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
|
||||
const STARTUP_TIMEOUT_MS = 12_000;
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
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)));
|
||||
}
|
||||
|
||||
async function legacyLinkFieldPids() {
|
||||
if (process.platform === 'win32') return [];
|
||||
let output;
|
||||
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
|
||||
catch (_) { return []; }
|
||||
const matches=[];
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match=line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);if(!match)continue;
|
||||
const pid=Number(match[1]),executable=path.basename(match[2]).toLowerCase(),command=match[3];
|
||||
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])server\.js(?:\s|$)/.test(command))continue;
|
||||
let cwd='';try{cwd=await fsp.readlink(`/proc/${pid}/cwd`)}catch(_){}
|
||||
const candidates=[cwd];const absolute=command.match(/(?:^|\s)(\/[^\s]*\/server\.js)(?:\s|$)/);if(absolute)candidates.push(path.dirname(absolute[1]));
|
||||
let linkField=false;
|
||||
for(const directory of candidates.filter(Boolean)){
|
||||
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(pkg?.name==='link-field-v47-shared-world'){linkField=true;break}}catch(_){}
|
||||
}
|
||||
if(linkField)matches.push(pid);
|
||||
}
|
||||
return [...new Set(matches)];
|
||||
}
|
||||
|
||||
async function stopLegacyLinkFieldServers() {
|
||||
const pids=await legacyLinkFieldPids();if(!pids.length)return [];
|
||||
for(const pid of pids)try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
|
||||
const started=Date.now();while(Date.now()-started<3000&&pids.some(isProcessRunning))await sleep(100);
|
||||
for(const pid of pids)if(isProcessRunning(pid))try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
|
||||
return pids;
|
||||
}
|
||||
|
||||
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 readPid() {
|
||||
try {
|
||||
const pid = Number((await fsp.readFile(PID_FILE, 'utf8')).trim());
|
||||
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStalePid() {
|
||||
const pid = await readPid();
|
||||
if (pid && isProcessRunning(pid)) return pid;
|
||||
await fsp.unlink(PID_FILE).catch(error => {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
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.stat(source);
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
async function deployPublicFiles(publicDir = resolvePublicDir()) {
|
||||
await fsp.mkdir(publicDir, {recursive:true, mode:0o755});
|
||||
if (path.resolve(publicDir) !== ROOT) {
|
||||
for (const entry of PUBLIC_ENTRIES) {
|
||||
const source = path.join(ROOT, entry);
|
||||
try {
|
||||
await copyEntry(source, path.join(publicDir, entry));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const manifest = {
|
||||
app: 'LinkField',
|
||||
version: require('../build-meta').APP_VERSION,
|
||||
source: ROOT,
|
||||
deployedAt: new Date().toISOString(),
|
||||
};
|
||||
await fsp.writeFile(path.join(publicDir, '.linkfield-deployment.json'), `${JSON.stringify(manifest, null, 2)}\n`, {encoding:'utf8', mode:0o644});
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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) return port;
|
||||
} 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();
|
||||
if (existingPid) {
|
||||
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
|
||||
await stop({quiet:true});
|
||||
}
|
||||
const stoppedLegacy=await stopLegacyLinkFieldServers();
|
||||
const publicDir = await deployPublicFiles();
|
||||
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
|
||||
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});
|
||||
const logFd = fs.openSync(LOG_FILE, 'a');
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [SERVER_FILE], {
|
||||
cwd: ROOT,
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT},
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(logFd);
|
||||
}
|
||||
if (!child.pid) throw new Error('Could not start the LinkField background process.');
|
||||
await fsp.writeFile(PID_FILE, `${child.pid}\n`, {encoding:'utf8', mode:0o600});
|
||||
child.unref();
|
||||
|
||||
try {
|
||||
const port = await waitForStartup(child.pid, publicDir);
|
||||
console.log('LinkField started in the background. The command prompt is available again.');
|
||||
console.log(`PID: ${child.pid}`);
|
||||
console.log(`Local port: ${port}`);
|
||||
console.log(`Public directory: ${publicDir}`);
|
||||
console.log(`Log: ${LOG_FILE}`);
|
||||
console.log("Check: curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'");
|
||||
} catch (error) {
|
||||
await fsp.unlink(PID_FILE).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop({quiet = false} = {}) {
|
||||
const pid = await readPid();
|
||||
if (!pid || !isProcessRunning(pid)) {
|
||||
await fsp.unlink(PID_FILE).catch(() => {});
|
||||
if (!quiet) console.log('LinkField is not running.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') throw error;
|
||||
}
|
||||
const started = Date.now();
|
||||
while (isProcessRunning(pid) && Date.now() - started < 5000) await sleep(100);
|
||||
if (isProcessRunning(pid)) {
|
||||
try { process.kill(pid, 'SIGKILL'); }
|
||||
catch (error) { if (error?.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
await fsp.unlink(PID_FILE).catch(() => {});
|
||||
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const pid = await readPid();
|
||||
const publicDir = resolvePublicDir();
|
||||
if (!pid || !isProcessRunning(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 {}
|
||||
console.log(`LinkField is running (PID ${pid}${port ? `, port ${port}` : ''}).`);
|
||||
console.log(`Public directory: ${publicDir}`);
|
||||
console.log(`Log: ${LOG_FILE}`);
|
||||
}
|
||||
|
||||
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 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 === '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, LOG_FILE, PUBLIC_ENTRIES, isProcessRunning, resolvePublicDir, deployPublicFiles, start, stop, status});
|
||||
if (require.main === module) main().catch(error => {
|
||||
console.error(`LinkField service command failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
273
server.js
273
server.js
|
|
@ -4,28 +4,61 @@ const http = require('http');
|
|||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
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 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 DEFAULT_DATA_ROOT = process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share');
|
||||
const DATA_DIR = path.resolve(process.env.BEND_FIELD_DATA_DIR || path.join(DEFAULT_DATA_ROOT, 'BendField', 'cloud-data'));
|
||||
const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT);
|
||||
const PRODUCTION_DATA_DIR = path.resolve('/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 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 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_ENABLED = !['0','false','off','no'].includes(String(process.env.LINK_FIELD_APACHE_BRIDGE || '').trim().toLowerCase());
|
||||
const PUBLIC_BRIDGE_PORT_FILE = path.join(PUBLIC_ROOT, '.linkfield-port');
|
||||
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BOARDS_PER_PUSH = 10000;
|
||||
const MAX_PATHS_PER_BOARD = 512;
|
||||
|
|
@ -71,19 +104,25 @@ function normalizePlayerPurchases(raw){
|
|||
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){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:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},occupancy:{},changes:[],clearEvents:[],createdAt:now,updatedAt:now}}
|
||||
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}
|
||||
try{
|
||||
const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');
|
||||
if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)return emptyWorld();
|
||||
value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};return value
|
||||
}catch(error){if(error.code==='ENOENT')return emptyWorld();throw error}
|
||||
}
|
||||
async function readWorldBoard(record,id){const revision=record.boardVersions[id];if(!Number.isSafeInteger(revision))return null;try{const value=JSON.parse(await fsp.readFile(worldBoardVersionPath(id,revision),'utf8'));return value&&typeof value==='object'?value:null}catch(error){if(error.code==='ENOENT')throw Object.assign(new Error(`Shared board is missing: ${id}`),{status:500});throw error}}
|
||||
async function ensureWorldIndexes(record){
|
||||
|
|
@ -150,7 +189,27 @@ 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:[];
|
||||
|
|
@ -174,8 +233,8 @@ function validateMeta(meta){
|
|||
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}`)}
|
||||
for(const item of paths){if(!item||typeof item!=='object'||!Array.isArray(item.cells)||!item.cells.length||item.cells.length>MAX_CELLS_PER_PATH)badRequest(`Invalid path for ${row.id}`);if(!Number.isInteger(item.startGate)||item.startGate<0||item.startGate>=puzzle.gates.length||item.endGate!=null&&(!Number.isInteger(item.endGate)||item.endGate<0||item.endGate>=puzzle.gates.length))badRequest(`Invalid path gates for ${row.id}`);if(item.detachedStart!==true&&!sameCellValue(item.cells[0],puzzle.gates[item.startGate])||item.endGate!=null&&!sameCellValue(item.cells[item.cells.length-1],puzzle.gates[item.endGate]))badRequest(`Path endpoint mismatch for ${row.id}`);const seen=new Set();for(let index=0;index<item.cells.length;index++){const cell=item.cells[index];if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1]))badRequest(`Invalid path cell for ${row.id}`);const key=cellKey(cell);if(!puzzle.valid.has(key)||seen.has(key))badRequest(`Invalid path cell for ${row.id}`);seen.add(key);if(index){const previous=item.cells[index-1],distance=Math.abs(previous[0]-cell[0])+Math.abs(previous[1]-cell[1]);if(distance!==1&&puzzle.warpMap.get(cellKey(previous))!==key)badRequest(`Disconnected path for ${row.id}`)}}}
|
||||
if(state.solved===true&&!solvedStateMatchesPuzzle(state,puzzle))badRequest(`Solved state does not satisfy puzzle rules for ${row.id}`)
|
||||
return state;
|
||||
}
|
||||
function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta));delete clean.rev;delete clean.revAuthor;delete clean.sealedSides;return JSON.stringify(clean)}
|
||||
|
|
@ -191,8 +250,10 @@ 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),
|
||||
|
|
@ -211,9 +272,11 @@ 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}};
|
||||
}
|
||||
|
|
@ -241,7 +304,7 @@ function storeItemPriceLocation(meta,state,store){
|
|||
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};
|
||||
}
|
||||
|
|
@ -284,12 +347,15 @@ const playerService=createPlayerService({
|
|||
findPurchase:purchaseForStoreItem,
|
||||
assertAffordable:assertPlayerCanAfford,
|
||||
createPurchase:createPlayerPurchase,
|
||||
starterLineColor:starterLineColorForPlayer,
|
||||
boardPattern:BOARD_RE
|
||||
});
|
||||
|
||||
async function handleCloudStatus(_req,res){
|
||||
return json(res,200,{available:true,sharedWorld:true,realtime:true,reactions:true,playerEconomy:true,sharedItems:false,claimTtlMs:realtimeHub?.claimTtlMs||300000,serverTime:serverTime()});
|
||||
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 body=await readJsonBody(req),result=await playerService.createSession(body.name);return json(res,result.status,result.body);
|
||||
}
|
||||
|
|
@ -308,10 +374,10 @@ async function handlePurchase(req,res){
|
|||
async function pullCloudWorldService(player,parameters){
|
||||
const record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(parameters.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(parameters.get('eventsSince'),0))),changed=since!==record.revision,
|
||||
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince);
|
||||
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime()}};
|
||||
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime()}};
|
||||
const cursor=Math.max(0,Math.floor(finiteNumber(parameters.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(parameters.get('at'),record.revision)));if(cursor&&at!==record.revision)return{status:409,body:{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()}};
|
||||
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds);
|
||||
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
|
||||
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
|
||||
}
|
||||
async function pushCloudWorldService(player,body){
|
||||
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
|
||||
|
|
@ -323,15 +389,15 @@ async function pushCloudWorldService(player,body){
|
|||
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
|
||||
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;if(firstSolve&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
|
||||
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true,unfinishedBoard=row.state?.solved!==true,existingBoard=existingIds.has(rawRow.id);if(record.revision>0&&existingBoard&&unfinishedBoard&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
|
||||
for(const [id,row] of changedBoards){if(!row.meta)badRequest(`Board metadata is missing: ${id}`);if(!row.state)row.state=publicStateForWorld({},null,playerRecord,nextRevision,rowRevision,row.meta).state}
|
||||
await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision}
|
||||
record.occupancy=occupancy;record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record)};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
|
||||
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);
|
||||
await commitWorldMutation(record,{nextPlayer:clearEvents.length?playerRecord:null,previousPlayer:clearEvents.length?previousPlayerRecord:null,changedBoardIds:changedBoards.keys()});
|
||||
for(const event of clearEvents)realtimeHub?.releaseBoardClaim(event.id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
|
||||
return{status:200,body:{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:{id:player.playerId,name:player.record.name},serverTime:record.updatedAt}};
|
||||
for(const[id,row]of changedBoards)if(row.state?.solved===true)realtimeHub?.releaseBoardClaim(id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
|
||||
return{status:200,body:{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:publicCloudPlayer(playerRecord),serverTime:record.updatedAt}};
|
||||
}));
|
||||
}
|
||||
async function handleCloudPull(req,res,url){
|
||||
|
|
@ -340,6 +406,24 @@ async function handleCloudPull(req,res,url){
|
|||
async function handleCloudPush(req,res){
|
||||
const player=await authenticatedPlayer(req),body=await readJsonBody(req),result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
|
||||
}
|
||||
async function pollingIdentity(req){
|
||||
const player=await authenticatedPlayer(req);return{playerId:player.playerId,name:player.record.name};
|
||||
}
|
||||
async function handleRealtimeConnect(req,res){
|
||||
const identity=await pollingIdentity(req),result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
|
||||
}
|
||||
async function handleRealtimeClaim(req,res){
|
||||
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.claimBoard(identity,body.boardId,body.presenceId);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
|
||||
}
|
||||
async function handleRealtimeSend(req,res){
|
||||
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.handlePollingMessage(identity,String(body.presenceId||''),body.message,body.afterSequence);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
|
||||
}
|
||||
async function handleRealtimePoll(req,res,url){
|
||||
const identity=await pollingIdentity(req),result=realtimeHub?.pollPollingClient(identity,String(url.searchParams.get('presenceId')||''),url.searchParams.get('after'));if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
|
||||
}
|
||||
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)
|
||||
|
|
@ -349,17 +433,23 @@ apiRouter
|
|||
.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/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}`)||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(pathname==='/'||pathname.endsWith('/')||pathname.endsWith('/debug-items'))pathname='/index.html';
|
||||
const candidates=[pathname];for(let offset=pathname.indexOf('/',1);offset>=0;offset=pathname.indexOf('/',offset+1))candidates.push(pathname.slice(offset));
|
||||
let file=null,stat=null,resolvedPathname=pathname;
|
||||
for(const candidate of candidates){const target=path.resolve(ROOT,`.${candidate}`);if(!target.startsWith(`${ROOT}${path.sep}`)||path.basename(target).startsWith('.'))continue;try{const targetStat=await fsp.stat(target);if(targetStat.isFile()){file=target;stat=targetStat;resolvedPathname=candidate;break}}catch(error){if(error.code!=='ENOENT')throw error}}
|
||||
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=pathname==='/runtime-config.js'?Buffer.from("'use strict';\nglobalThis.BendRuntimeConfig=Object.freeze({cloudApi:true});\n"):null;
|
||||
const ext=path.extname(file).toLowerCase(),body=resolvedPathname==='/runtime-config.js'?Buffer.from("'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'http-poll'});})(globalThis);\n"):null;
|
||||
res.writeHead(200,{
|
||||
'content-type':MIME[ext]||'application/octet-stream',
|
||||
'content-length':body?body.length:stat.size,
|
||||
|
|
@ -374,6 +464,125 @@ async function serveStatic(req,res,url){
|
|||
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);
|
||||
}
|
||||
async function main(){await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});await recoverPendingWorldCommit();await collectRetiredBoardVersions();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 v${BuildMeta.APP_VERSION} shared world: http://${HOST}:${PORT}`);console.log(`Shared world data: ${DATA_DIR}`)})}
|
||||
module.exports=Object.freeze({normalizePlayerPurchases,sanitizeWorldGlobal,storeItem,collectRetiredBoardVersions,recoverPendingWorldCommit,commitWorldMutation,main});
|
||||
if(require.main===module)main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
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() {
|
||||
return http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`),apiOffset=url.pathname.lastIndexOf('/api/');if(apiOffset>=0){url.pathname=url.pathname.slice(apiOffset);await handleApi(req,res,url)}else await serveStatic(req,res,url)}catch(error){console.error(error);if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Internal server error',serverTime:serverTime()});else res.destroy()}});
|
||||
}
|
||||
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 acquireInstanceLock(){
|
||||
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
|
||||
for(let attempt=0;attempt<2;attempt++){
|
||||
try{
|
||||
const handle=await fsp.open(INSTANCE_LOCK_FILE,'wx',0o600);
|
||||
await handle.writeFile(`${process.pid}
|
||||
`,'utf8');
|
||||
await handle.close();
|
||||
return process.pid;
|
||||
}catch(error){
|
||||
if(error?.code!=='EEXIST')throw error;
|
||||
let existing=0;try{existing=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim())}catch(_){}
|
||||
if(processIsRunning(existing))throw Object.assign(new Error(`Another LinkField server is already running (PID ${existing}). Stop it before starting a second shared world.`),{code:'EALREADY'});
|
||||
await fsp.unlink(INSTANCE_LOCK_FILE).catch(unlinkError=>{if(unlinkError?.code!=='ENOENT')throw unlinkError});
|
||||
}
|
||||
}
|
||||
throw new Error('Could not acquire the LinkField single-world lock.');
|
||||
}
|
||||
async function releaseInstanceLock(){
|
||||
try{const owner=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim());if(owner===process.pid)await fsp.unlink(INSTANCE_LOCK_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Instance lock cleanup warning: ${error.message}`)}
|
||||
}
|
||||
async function main(){
|
||||
await acquireInstanceLock();
|
||||
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
|
||||
await fsp.mkdir(PUBLIC_ROOT,{recursive:true,mode:0o755});
|
||||
await recoverPendingWorldCommit();
|
||||
await collectRetiredBoardVersions();
|
||||
const server=createApplicationServer();
|
||||
realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo});
|
||||
try {
|
||||
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}`);
|
||||
return {server,realtimeHub,port:listening.port,host:HOST,dataDir:DATA_DIR,publicRoot:PUBLIC_ROOT,portFile:PUBLIC_BRIDGE_PORT_FILE,apacheBridge};
|
||||
} catch (error) {
|
||||
realtimeHub?.close();
|
||||
realtimeHub=null;
|
||||
await fsp.unlink(PUBLIC_BRIDGE_PORT_FILE).catch(()=>{});
|
||||
await releaseInstanceLock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function closeApplication(result){
|
||||
if(!result)return;
|
||||
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});
|
||||
}
|
||||
|
|
|
|||
62
server/apache-bridge.js
Normal file
62
server/apache-bridge.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'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 renderApacheBridge(portValue) {
|
||||
const port = normalizePort(portValue);
|
||||
return `${BEGIN_MARKER}\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` +
|
||||
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\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` +
|
||||
`</IfModule>\n` +
|
||||
`<Files ".linkfield-port">\n` +
|
||||
` Require all denied\n` +
|
||||
`</Files>\n` +
|
||||
`${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, replaceManagedBlock, installApacheBridge});
|
||||
|
|
@ -5,14 +5,18 @@ function createPlayerService(deps){
|
|||
randomHex,now,cleanName,defaultName,hashToken,writePlayer,readPlayer,
|
||||
withPlayerQueue,withWorldQueue,readWorld,readWorldBoard,normalizeBonuses,
|
||||
earnedScore,publicState,bonusAmount,bonusDelayMs,notifyProfile,
|
||||
purchaseContext,findPurchase,assertAffordable,createPurchase,boardPattern
|
||||
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:[],earnedScore:0,economyRevision:0,createdAt:timestamp,updatedAt:timestamp};
|
||||
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,revision:0,serverTime:timestamp}};
|
||||
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});
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@
|
|||
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;
|
||||
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,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
87
style.css
87
style.css
|
|
@ -12,6 +12,7 @@
|
|||
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}
|
||||
|
|
@ -98,6 +99,9 @@
|
|||
.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}
|
||||
|
|
@ -206,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}
|
||||
|
|
@ -232,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}
|
||||
|
|
@ -242,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}
|
||||
|
|
@ -257,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}
|
||||
|
|
@ -279,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)}}
|
||||
|
|
@ -292,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}
|
||||
|
|
@ -319,13 +335,6 @@ body.archive-busy #viewport,body.archive-busy #minimap{opacity:.58}
|
|||
.toolbar .pill{min-width:0;font-size:9px}
|
||||
}
|
||||
|
||||
.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}}
|
||||
|
||||
|
|
@ -440,7 +449,6 @@ 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}
|
||||
|
|
@ -486,6 +494,9 @@ body[data-cursor-style="flame-orange"] :is(#viewport,.board-svg,.board-input-sur
|
|||
.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}
|
||||
|
|
@ -532,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. */
|
||||
|
|
@ -575,6 +586,11 @@ 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}
|
||||
|
|
@ -637,15 +653,7 @@ body.lightweight-rendering .score-lens-badge{box-shadow:none!important;text-shad
|
|||
.settings-actions{justify-content:flex-end}.settings-actions #resetSettings{margin-right:4px}
|
||||
|
||||
|
||||
/* v47.83: active board emphasis, aligned settings actions, and cyber-pop stores. */
|
||||
.active-board-boundary-layer{display:none;pointer-events:none}
|
||||
.board-card.hud-current:not(.solved) .active-board-boundary-layer{display:block}
|
||||
.active-board-boundary-shadow,.active-board-boundary-dash{fill:none;vector-effect:non-scaling-stroke;pointer-events:none}
|
||||
.active-board-boundary-shadow{stroke:color-mix(in srgb,var(--region) 72%,#7cf7ff);stroke-width:9;opacity:.48;filter:drop-shadow(0 0 8px rgba(73,238,255,.8)) drop-shadow(0 0 16px rgba(255,71,199,.36))}
|
||||
.active-board-boundary-dash{stroke:#f5feff;stroke-width:9;stroke-dasharray:1 18;stroke-linecap:round;animation:activeBoardOrbit 9s linear infinite,activeBoardBreathe 3.4s ease-in-out infinite}
|
||||
@keyframes activeBoardOrbit{to{stroke-dashoffset:-96}}
|
||||
@keyframes activeBoardBreathe{0%,100%{opacity:.34}50%{opacity:1}}
|
||||
body.lightweight-rendering .active-board-boundary-layer{display:none!important}
|
||||
/* 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}
|
||||
|
|
@ -665,3 +673,14 @@ body.lightweight-rendering .active-board-boundary-layer{display:none!important}
|
|||
.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)}}
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
|
|
@ -278,10 +281,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 +298,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 +311,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 +319,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 +333,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})()");
|
||||
}
|
||||
}
|
||||
|
|
@ -398,12 +404,89 @@ async function measureCursorCadence(client,steps=180){
|
|||
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>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify({gap,diagnostic:measured.diagnostic})}`);
|
||||
assert(age.count>=25&&age.p95<30,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
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("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){
|
||||
const center=await viewportCenter(client);
|
||||
for(let index=0;index<repetitions;index++){
|
||||
|
|
@ -413,7 +496,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};
|
||||
|
|
@ -470,14 +553,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,functionalDragLimit=cpuRate===1?16:24,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)}`);
|
||||
|
|
@ -490,10 +573,10 @@ function validateMeasurement(result){
|
|||
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<=28,`${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(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`);
|
||||
|
|
@ -506,9 +589,8 @@ function validateMeasurement(result){
|
|||
}
|
||||
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`);
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +627,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');
|
||||
|
|
@ -554,7 +637,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'})}
|
||||
|
|
@ -564,9 +647,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]){
|
||||
|
|
@ -577,6 +677,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();
|
||||
|
|
@ -585,45 +686,41 @@ 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.83'&&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==='v47.87'&&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);
|
||||
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);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
@ -176,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={
|
||||
|
|
@ -231,7 +209,6 @@ function verifyRevisionSeed(){
|
|||
(async()=>{
|
||||
await verifyPersistenceConflicts();
|
||||
verifyGlobalMerge();
|
||||
await verifySignalRetry();
|
||||
await verifyDurableBackup();
|
||||
verifyRevisionSeed();
|
||||
console.log('Concurrency and replacement safety test passed');
|
||||
|
|
|
|||
51
test/effects-performance-smoke-test.js
Normal file
51
test/effects-performance-smoke-test.js
Normal 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');
|
||||
|
|
@ -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:()=>{},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
130
test/fixtures/effect-visual-checkpoints.json
vendored
Normal file
130
test/fixtures/effect-visual-checkpoints.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -116,9 +116,9 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
|
|||
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('.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()')<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.
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ 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}),puzzleOf:meta=>meta.puzzle
|
||||
|
|
@ -208,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);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&app.includes('REALTIME_CURSO
|
|||
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('remotePlayers'),'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('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');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})});
|
||||
|
|
|
|||
|
|
@ -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(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==='v47-field-reset-20260728-interaction-fix','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');
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ const fs=require('fs');
|
|||
const {execFileSync}=require('child_process');
|
||||
const tests=[
|
||||
'shared-contracts-test.js','interaction-ownership-test.js','frame-drag-scheduler-test.js','architecture-boundaries-test.js','source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','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-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','v4785-cosmetics-shop-smoke-test.js','v4786-effects-ux-smoke-test.js','v4787-user-cosmetic-realtime-smoke-test.js','v4788-time-attack-navigation-smoke-test.js','v4784-user-request-smoke-test.js','effects-performance-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
];
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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,7 @@ 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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'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(),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,10]];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);
|
||||
(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');
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ const fs=require('fs');
|
|||
const os=require('os');
|
||||
const path=require('path');
|
||||
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-recovery-'));
|
||||
process.env.BEND_FIELD_DATA_DIR=dataDir;
|
||||
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},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});
|
||||
|
|
@ -37,4 +38,4 @@ const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[
|
|||
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
|
||||
assert.equal(fs.existsSync(commitFile),false);
|
||||
console.log('Shared-world commit recovery and retired-version collection passed');
|
||||
})().finally(()=>fs.rmSync(dataDir,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
})().finally(()=>fs.rmSync(dataRoot,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
|
|||
|
|
@ -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,8 +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 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;
|
||||
|
|
@ -37,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);
|
||||
|
||||
|
|
@ -63,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})});
|
||||
|
|
|
|||
|
|
@ -48,5 +48,16 @@ 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,false,'Static/file mode must not probe the cloud API');
|
||||
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,'http-poll','Static hosting must use HTTP realtime 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');
|
||||
|
|
|
|||
|
|
@ -11,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={
|
||||
|
|
@ -26,10 +26,8 @@ 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};
|
||||
|
|
@ -51,7 +49,7 @@ const authoritativeContext={
|
|||
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);
|
||||
|
|
@ -76,9 +74,15 @@ assert.equal(authoritativeContext.data.states.B0.solved,true,'A durable clear wa
|
|||
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 keep the player's unfinished line locally while the board definition stays shared.
|
||||
// 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');
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ assert(functionSource('inventoryEntries').includes('personalEconomyMode()')&&fun
|
|||
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}}
|
||||
|
|
@ -30,14 +30,14 @@ 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(),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,10]];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=10;puzzle.totalTurns=10;
|
||||
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:'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);
|
||||
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 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'})})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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==='v47-field-reset-20260728-interaction-fix','Canonical build metadata does not match the package or persistence contracts');
|
||||
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 [
|
||||
'SPECIAL_CELL_MIN_LEVEL=5',
|
||||
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
|
||||
|
|
@ -28,8 +28,8 @@ assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared
|
|||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
|
||||
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
|
||||
assert(functionSource('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');
|
||||
|
|
@ -47,7 +47,7 @@ assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remai
|
|||
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');
|
||||
|
|
@ -56,7 +56,7 @@ assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includ
|
|||
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(!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('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'Lightweight FPS display 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'),'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')&&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');
|
||||
|
||||
|
|
@ -94,11 +94,11 @@ assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')
|
|||
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(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===101&&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 yellow-face cursor prices do not span 500-50000 gems');
|
||||
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');
|
||||
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'));
|
||||
|
|
@ -107,10 +107,10 @@ assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagA
|
|||
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('所持ジェム')&&!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('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');
|
||||
|
|
@ -151,7 +151,7 @@ for(let level=1;level<=10;level++){
|
|||
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');
|
||||
|
|
@ -160,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`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -95,10 +96,10 @@ async function waitForServer(){
|
|||
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/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');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path,buildMeta}=require('./helpers/app-source');
|
||||
assert(buildMeta.APP_VERSION==='47.83'&&buildMeta.PACKAGE_VERSION==='47.83.0','Version was not advanced to v47.83');
|
||||
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');
|
||||
|
|
@ -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.83 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');
|
||||
|
|
|
|||
|
|
@ -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.83 active 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');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ for(const name of ['scheduleBoardDragFrame','queueCameraInteraction','scheduleWo
|
|||
const source=functionSource(name);
|
||||
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`);
|
||||
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`);
|
||||
|
|
@ -23,5 +24,5 @@ assert(camera.includes('minimapDirty=true'),'Camera movement must mark, not imme
|
|||
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(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 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');
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=prese
|
|||
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(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 active HUD, cursor coverage, and capped knob tracking 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');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(pat
|
|||
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=34'),'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_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');
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ assert(functionSource('finalizeAtGate').includes('usesLightweightDragOverlay(b)'
|
|||
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("interactionActive('persistence')"),'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('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');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'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==='47.83','v47.83 canonical version marker is missing');
|
||||
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');
|
||||
|
|
@ -20,4 +20,4 @@ const hudStyle={removeProperty(name){delete this[name]}},hudLabel={hidden:true,c
|
|||
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.83 settings, pan rendering, detached HUD, cursor, and knob regression test passed');
|
||||
console.log('v47.84 settings, pan rendering, detached HUD, cursor, and knob regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'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==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const bindBoard=functionSource('bindBoard');
|
||||
assert(bindBoard.includes('const release=(paintCurrent=false)=>{')&&bindBoard.includes('cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay()'),'Pointer release does not synchronously stop the drag scheduler and remove the pickup overlay');
|
||||
|
|
@ -13,10 +13,10 @@ assert(css.includes('#pickupHandleOverlay.custom-cursor.visible{display:grid}')&
|
|||
assert(functionSource('syncCursorAppearance').includes('syncPickupHandleDesign()'),'Changing cursor style does not refresh the pickup appearance');
|
||||
|
||||
const merge=functionSource('mergeSnapshotIntoData');
|
||||
assert(merge.includes('authoritativeCompatibleStateIds=authoritativeWorld?new Set():null')&&merge.includes('retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true')&&merge.includes("if(retainLocalSolve)noteCloudRow('state',id)")&&merge.includes('merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming)')&&!merge.includes('current?.solved&&!incoming.solved?deepClone(incoming)'),'Authoritative pull can still downgrade a compatible local clear or reuse a clear across replaced board geometry');
|
||||
assert(merge.includes('authoritativeCompatibleStateIds=authoritativeWorld?new Set():null')&&merge.includes('retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true')&&merge.includes('retainClaimedPending=compatible')&&merge.includes("if(retainLocalSolve){noteCloudRow('state',id);merged=deepClone(current)}")&&merge.includes('else{clearSharedWorldJournalRow')&&!merge.includes('merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming)'),'Authoritative pull can still downgrade a compatible local clear, erase a claimant retry, or merge stale unfinished paths');
|
||||
const completion=functionSource('checkSolvedAndExpand');
|
||||
const cloudFailure=completion.slice(completion.indexOf('const published=await pushCloudPending()'),completion.indexOf('let durableMeta='));
|
||||
assert(completion.includes('writeDirtyRecoveryJournal();')&&cloudFailure.includes("toast('クリアは端末に保存しました。共有反映は自動で再試行します。')")&&cloudFailure.includes('armCloudPush(1000)')&&!cloudFailure.includes('data.states[b.id]=previous.state'),'Completion is not checkpointed immediately or is still rolled back on a transient cloud failure');
|
||||
const cloudFailure=completion.slice(completion.indexOf('let published=false'),completion.indexOf('const immediateBoard='));
|
||||
assert(completion.includes('writeDirtyRecoveryJournal();')&&cloudFailure.includes('for(let attempt=0;attempt<3&&!published;attempt++)')&&cloudFailure.includes('data.states[b.id]=previous.state')&&cloudFailure.includes('await persistNow({skipCloud:true})'),'Completion is not verified by the shared server or is left locally solved after publication failure');
|
||||
|
||||
const overlay={dataset:{},classList:{values:new Set(),toggle(name,on){on?this.values.add(name):this.values.delete(name)}},replaceChildren(node){this.child=node;this.textContent=''},textContent:'',style:{}};
|
||||
const cursorItems=new Map([['emoji-test',{cursorStyle:'emoji-test',cursorEmoji:'🙂'}]]);
|
||||
|
|
@ -26,4 +26,4 @@ assert(context.syncPickupHandleDesign()===true&&overlay.textContent==='🙂'&&ov
|
|||
context.data.cursorStyle='flag-test';cursorItems.set('flag-test',{cursorStyle:'flag-test',flagAsset:'flag.svg'});context.syncPickupHandleDesign();
|
||||
assert(overlay.child?.src==='flag.svg'&&overlay.classList.values.has('flag-cursor'),'Flag cursor was not applied to the pickup overlay');
|
||||
|
||||
console.log('v47.83 pickup release, durable completion, and cursor-design regression test passed');
|
||||
console.log('v47.84 pickup release, durable completion, and cursor-design regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const hud=functionSource('boardPlayHudVisible'),activateHud=functionSource('activateBoardHud'),select=functionSource('selectBoard'),activateDrag=functionSource('activateBoardPointerDrag'),makeBoard=functionSource('makeBoard');
|
||||
assert(hud.includes('hudBoardId===b?.id')&&!hud.includes('b?.drawing'),'HUD still disappears when the pointer draw ends');
|
||||
|
|
@ -16,9 +16,10 @@ assert(overlay.includes('hidePickupHandleOverlay();return false'),'An invalidate
|
|||
assert(finish.includes('if(!b.drawing){cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease'),'Pointer-up fallback does not clear a stale pickup overlay');
|
||||
|
||||
const inventoryRender=functionSource('renderInventoryPanel'),inventorySync=functionSource('syncInventoryCursorSelection'),inventoryUse=functionSource('useInventoryItemLoaded');
|
||||
assert(inventoryRender.includes('option.dataset.itemId=item.id')&&inventoryRender.includes('event.preventDefault()'),'Cursor inventory options lack stable item identity or click-default suppression');
|
||||
assert(inventorySync.includes("querySelectorAll('.inventory-cursor-option[data-item-id]')")&&inventorySync.includes("classList.toggle('selected',selected)")&&inventorySync.includes("setAttribute('aria-pressed',String(selected))"),'Cursor selection cannot update in place');
|
||||
const cursorBranch=inventoryUse.slice(inventoryUse.indexOf('if(item.cursorStyle)'),inventoryUse.indexOf('if(item.scoreLens)'));
|
||||
assert(functionSource('createInventoryItemView').includes('option.dataset.itemId=item.id')&&functionSource('createInventoryItemView').includes('event.preventDefault()'),'Cursor inventory options lack stable item identity or click-default suppression');
|
||||
assert(inventorySync.includes('inventorySelectedCursorItemId')&&inventorySync.includes('patchInventoryItems([inventorySelectedCursorItemId,next])'),'Cursor selection cannot update in place');
|
||||
const cursorBranch=inventoryUse.slice(inventoryUse.indexOf('if(item.cursorStyle)'),inventoryUse.indexOf('if(item.lineColor)'));
|
||||
assert(cursorBranch.includes('syncInventoryCursorSelection()')&&!cursorBranch.includes('renderInventoryPanel()')&&!cursorBranch.includes('updateHud()'),'Cursor switching still rebuilds the inventory panel and can move its scroll position');
|
||||
assert(functionSource('renderInventoryPanel').includes('restorePanelScroll(inventoryPanel,scrollPosition)')&&functionSource('renderStorePanel').includes('restorePanelScroll(storePanel,scrollPosition)'),'Item-list rerenders can reset the inventory or shop scroll position');
|
||||
|
||||
console.log('v47.83 persistent board HUD, gate-overlay cleanup, and inventory-scroll regression test passed');
|
||||
console.log('v47.84 persistent board HUD, gate-overlay cleanup, and inventory-scroll regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const sound=functionSource('playSound');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=5.2')&&functionSource('soundTone').includes('Math.min(.42')&&functionSource('soundNoise').includes('Math.min(.24'),'Requested sound level increase is missing');
|
||||
|
|
@ -9,9 +9,7 @@ for(const kind of['grab','stretch','gate','clear','buy','reset','remove','shop',
|
|||
assert(functionSource('finalizeAtGate').includes("playSound('gate')")&&functionSource('removeDetachedPathAtOwnEndpoint').includes("playSound('remove')")&&functionSource('beginMinimapPointer').includes("playSound('shop')"),'New sound variants are not wired to gameplay events');
|
||||
|
||||
const makeBoard=functionSource('makeBoard');
|
||||
assert(makeBoard.includes("class:'active-board-boundary-layer'")&&makeBoard.includes("class:'active-board-boundary-shadow'")&&makeBoard.includes("class:'active-board-boundary-dash'"),'Active-board boundary layers are missing');
|
||||
assert(css.includes('.board-card.hud-current:not(.solved) .active-board-boundary-layer{display:block}')&&css.includes('@keyframes activeBoardOrbit')&&css.includes('@keyframes activeBoardBreathe'),'Active-board orbit/blink styling is missing');
|
||||
assert(css.includes('body.lightweight-rendering .active-board-boundary-layer{display:none!important}'),'Active-board boundary remains visible in lightweight rendering');
|
||||
assert(!makeBoard.includes('active-board-boundary')&&!css.includes('active-board-boundary')&&!css.includes('@keyframes activeBoardOrbit'),'Retired active-board orbit boundary remains');
|
||||
|
||||
assert(html.includes('<div class="modal-actions settings-actions"><button class="pill quiet" id="resetSettings"')&&html.includes('</button><button class="pill close" id="closeSettings"'),'Settings reset and close buttons are not adjacent in document order');
|
||||
assert(css.includes('.settings-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}')&&css.includes('.settings-actions .pill{flex:1 1 0;width:auto;margin:0!important}'),'Settings actions are not horizontally aligned');
|
||||
|
|
@ -35,4 +33,4 @@ assert(gatePoint.includes('if(g.internal)return[x,y]')&&outside.includes('if(g?.
|
|||
assert(makeBoard.includes("'aria-label':g.internal?`盤面内ゲート ${i+1}`")&&makeBoard.includes('gateHitBox(gp,g.side,g.internal)'),'Internal gates do not share normal gate input affordances');
|
||||
for(const name of['matchingNeighborGate','gateFrontierCandidates','missingGateConnections','placementConnectionRequirements'])assert(functionSource(name).includes('internalGates'),`${name} can misclassify an internal gate as a world-expansion gate`);
|
||||
|
||||
console.log('v47.83 audio, active-board emphasis, shop navigation, removal repaint, and internal-gate regression test passed');
|
||||
console.log('v47.84 audio, active-board emphasis, shop navigation, removal repaint, and internal-gate regression test passed');
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ const vm=require('vm');
|
|||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const server=read('server.js');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
assert(html.includes('<div class="minimap-head"><b>マップ</b></div>')&&!html.includes('minimapStatus')&&!html.includes('周辺マップ'),'Map title or count removal is incomplete');
|
||||
assert(html.includes('<span class="shop">ショップ</span>')&&functionSource('makeBoard').includes('<small>ショップ</small>'),'Shop labels were not localized');
|
||||
assert(!functionSource('drawMinimap').includes('nearbyPlayers')&&!functionSource('drawMinimap').includes('visibleMetas.length')&&!functionSource('drawMinimap').includes('storeCount||0'),'Map still exposes player, board, or shop counts');
|
||||
assert(css.includes('.active-board-boundary-dash{stroke:#f5feff;stroke-width:9;stroke-dasharray:1 18'),'Active-board orbit dots were not thickened to the shadow scale');
|
||||
assert(!css.includes('.active-board-boundary-dash'),'Retired active-board orbit dots remain');
|
||||
|
||||
const renderDrag=functionSource('renderDragFrame'),overlay=functionSource('updatePickupHandleOverlay');
|
||||
assert(functionSource('activeDrawingLineColorIndex').includes('startColorIndex')&&overlay.includes('activeDrawingLineColorIndex(path)'),'Pickup overlay color is not derived from the actively drawn line');
|
||||
|
|
@ -23,7 +23,7 @@ assert(server.includes('meta?.puzzle?.obstacles||[]')&&server.includes('pathInde
|
|||
assert(functionSource('personalEconomyMode').includes('cloudProfile'),'Personal economy mode is not stable across transient connectivity changes');
|
||||
for(const name of['inventoryEntries','spentScoreTotal','pruneAndCount'])assert(functionSource(name).includes('personalEconomyMode()'),`${name} can fall back to shared-board purchases during an outage`);
|
||||
assert(functionSource('purchaseStoreItem').includes("personalEconomyMode()&&!onlinePlayerEconomy()")&&functionSource('purchaseStoreItem').includes('共有の所持数を確認できないため購入できません。'),'Offline personal purchases are not blocked safely');
|
||||
assert(functionSource('mergeGlobalRecords').includes('playerEarnedScore=Math.max')&&functionSource('applyPlayerEconomyEnvelope').includes('playerEarnedScore=Math.max'),'A stale global/player response can still reduce earned gems');
|
||||
assert(functionSource('applyPlayerEconomyEnvelope').includes('data.playerEarnedScore=Number.isSafeInteger(player.earnedScore)')&&!functionSource('applyPlayerEconomyEnvelope').includes('playerEarnedScore=Math.max'),'Personal gem balance is not server-authoritative');
|
||||
assert(functionSource('rememberStateSignatures').includes('!personalEconomyMode()'),'World-store purchase deltas can still reduce personal gems');
|
||||
|
||||
assert(functionSource('finalizeAtGate').includes('commitConnectedLineVisuals(b,pi)')&&functionSource('joinTips').includes('commitConnectedLineVisuals'),'Connected-line visuals are not scheduled after connection');
|
||||
|
|
@ -31,7 +31,7 @@ assert(functionSource('commitConnectedLineVisuals').includes('invalidateLineGrap
|
|||
|
||||
const preserveSource=functionSource('preserveSolvedBoardState');
|
||||
assert(preserveSource.includes('solvedSource?._summaryOnly?baseState:solvedSource')&&preserveSource.includes('preserved.solved=true')&&preserveSource.includes('delete preserved._summaryOnly'),'Solved summary recovery can still erase full route data');
|
||||
assert(functionSource('applyWorldSignal').includes('current?.solved&&!incoming.solved&&compatible')&&functionSource('applyWorldSignal').includes('preserveSolvedBoardState(current,incoming)'),'Cross-tab stale unsolved records can still overwrite a clear');
|
||||
assert(!app.includes('applyWorldSignal')&&!app.includes('BroadcastChannel'),'Retired cross-tab board reconciliation remains');
|
||||
assert(functionSource('hydrateMeta').includes('currentState?.solved&&!loadedState.solved')&&functionSource('hydrateMeta').includes('preserveSolvedBoardState(currentState,loadedState)'),'Rehydration can still restore an older unsolved state');
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('summarizeBoardV2(target,currentState'),'Eviction can still cache a stale pre-clear summary');
|
||||
|
||||
|
|
@ -41,4 +41,4 @@ const summary={_summaryOnly:true,solved:true,expanded:true,solvedBy:'A',scoreAwa
|
|||
const preserved=context.preserveSolvedBoardState(summary,incoming);
|
||||
assert(preserved.solved===true&&preserved.paths.length===1&&preserved.paths[0].cells.length===2&&!('_summaryOnly' in preserved),'Solved-summary repair did not retain incoming full route data');
|
||||
|
||||
console.log('v47.83 map, store, economy, line inheritance, and clear persistence regression test passed');
|
||||
console.log('v47.84 map, store, economy, line inheritance, and clear persistence regression test passed');
|
||||
|
|
|
|||
20
test/v4784-user-request-smoke-test.js
Normal file
20
test/v4784-user-request-smoke-test.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
const {assert,app,css,html,read,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),server=read('server.js');
|
||||
const retired=['glow','pulse','dash','starlight','neon','prism','comet'];
|
||||
assert(retired.every(effect=>!catalog.some(item=>item.lineEffect===effect)&&!css.includes(`line-effect-${effect}`)),`A retired/light-emitting line effect remains: ${retired.find(effect=>catalog.some(item=>item.lineEffect===effect)||css.includes(`line-effect-${effect}`))}`);
|
||||
const aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(aurora?.lineColor&&aurora.aurora===true&&!aurora.lineEffect&&catalog.filter(item=>item.lineEffect).length===0,'Aurora is not classified as a shop-only line color');
|
||||
assert(app.includes('AURORA_COLOR_INTERVAL=2000')&&app.includes('AURORA_RGB_PALETTE=Object.freeze')&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex')&&css.includes('transition:stroke 2s linear')&&css.includes('.gate-dot.line-effect-aurora'),'Aurora does not use the curated two-second line/gate palette');
|
||||
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('REACTION_TARGET_FPS=30')&&app.includes('DRAG_TARGET_FPS=30')&&functionSource('applyCamera').includes('GLOBAL_FRAME_INTERVAL'),'Not all manual visual pipelines are capped at 30 FPS');
|
||||
assert(app.includes("location.pathname.endsWith('/debug-items')")&&server.includes("pathname.endsWith('/debug-items')")&&!html.includes('debugAllItemsToggle')&&!app.includes('デバッグ使用可'),'Debug access is not URL-only or still leaks into the item UI');
|
||||
assert(functionSource('purchaseStoreItem').includes('paidCost:0')&&functionSource('purchaseStoreItem').includes('if(!debug&&data.score<price)'),'Debug URL does not retain the normal purchase flow with free debug settlement');
|
||||
const seeded=functionSource('seededStoreItemIds');
|
||||
assert(seeded.includes('cursorPool.slice(0,6)')&&seeded.includes('6-fixedTools.length')&&seeded.includes('return[...cursorPool.slice(0,6),...selectedOthers]'),'Store stock is not six cursors followed by six seeded non-cursor items');
|
||||
const store=functionSource('renderStorePanel');
|
||||
assert(store.includes("title:'カーソル'")&&store.includes("title:'その他のアイテム'")&&store.includes('.slice(0,6)')&&!app.includes('陳列は店ごとに異なります')&&!app.includes('便利な機能から6点を陳列しています。'),'Shop layout or removed copy is incorrect');
|
||||
const inventoryCategory=functionSource('createInventoryCategoryView');
|
||||
assert(inventoryCategory.includes("document.createElement('details')")&&inventoryCategory.includes("document.createElement('summary')")&&inventoryCategory.includes('inventoryCollapsedCategories'),'Inventory categories cannot be collapsed');
|
||||
assert(!app.includes("title:'Line Colors'")&&!app.includes("title:'Line Effects'")&&!app.includes("title:'Emoji Effects'")&&!app.includes("title:'Tools'")&&!app.includes("title:'Cursors'")&&!html.includes('>SETTINGS<')&&!html.includes('>DEBUG<'),'Visible English category or panel labels remain');
|
||||
assert(buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801'&&app.includes('STARTER_SEED=0x9c37a5e1')&&functionSource('readWorld',server).includes('value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION'),'Board generation was not fully reset with a new seed on client and server');
|
||||
console.log('v47.87 user-requested Aurora, debug URL, FPS, shop, and inventory guards passed');
|
||||
35
test/v4785-cosmetics-shop-smoke-test.js
Normal file
35
test/v4785-cosmetics-shop-smoke-test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'use strict';
|
||||
const {assert,app,css,read,functionSource,vm}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),generatedContext={};
|
||||
vm.createContext(generatedContext);vm.runInContext(read('store-catalog.generated.js'),generatedContext);const generated=generatedContext.BendStoreCatalog;
|
||||
assert(JSON.stringify(generated)===JSON.stringify(catalog),'Generated store catalog is not synchronized with store-catalog.json');
|
||||
assert(catalog.length===380&&new Set(catalog.map(item=>item.id)).size===catalog.length,'Expanded store catalog count or IDs are invalid');
|
||||
const colors=catalog.filter(item=>item.lineColor),lineEffects=catalog.filter(item=>item.lineEffect),reactionEffects=catalog.filter(item=>item.reactionStyle),cursors=catalog.filter(item=>item.cursorStyle),aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(colors.length===15&&lineEffects.length===0&&reactionEffects.length===5&&cursors.length===359&&aurora?.aurora===true,'The requested color, reaction-effect, or cursor inventory is incomplete');
|
||||
assert(catalog.every(item=>[item.cursorStyle,item.scoreLens===true,item.lineColor,item.lineEffect,item.reactionStyle].filter(Boolean).length===1),'A catalog item mixes multiple cosmetic/tool contracts');
|
||||
const retiredReactions=['fountain','shockwave','rain'];
|
||||
assert(retiredReactions.every(style=>!reactionEffects.some(item=>item.reactionStyle===style)&&!read('realtime-server.js').includes(`'${style}'`)),`A retired emoji effect remains active`);
|
||||
const retiredLineEffects=['neon','prism','comet','glow','pulse','dash','starlight'];
|
||||
assert(retiredLineEffects.every(style=>!lineEffects.some(item=>item.lineEffect===style)),`A retired line effect remains active`);
|
||||
const removed=['cursor-face-1f642-200d-2194-fe0f','cursor-face-1f642-200d-2195-fe0f','cursor-face-1fae9','cursor-face-1faea'];
|
||||
assert(removed.every(id=>!catalog.some(item=>item.id===id)&&!app.includes(`'${id}'`)),`A removed glitchy/undisplayed cursor remains`);
|
||||
const defaultData=functionSource('defaultData'),normalize=functionSource('normalizeSnapshot'),newPath=functionSource('startGate'),pathNormalization=functionSource('normalizePath'),appearance=functionSource('syncCosmeticAppearance'),migration=functionSource('migratedLineColorStyle');
|
||||
assert(defaultData.includes('starterLineColor=randomStarterLineColorId()')&&defaultData.includes('lineColorStyle:starterLineColor'),'A first-access player is not granted and equipped a random starter line color');
|
||||
assert(functionSource('validStarterLineColorId').includes('STARTER_LINE_COLOR_IDS.includes(value)')&&normalize.includes('validStarterLineColorId(raw.starterLineColor)'),'A premium shop color can be forged as a free starter color');
|
||||
assert(normalize.includes('clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor')&&normalize.includes("clean.reactionStyle=REACTION_STYLE_IDS.has(raw.reactionStyle)")&&!normalize.includes('ownsSavedItem'),'Saved equipped cosmetics are incorrectly reset before purchase data hydrates');
|
||||
assert(migration.includes("source?.lineEffectStyle==='aurora'")&&migration.includes('AURORA_LINE_COLOR_ITEM_ID'),'Legacy Aurora equipment is not migrated to the line-color contract');
|
||||
assert(functionSource('starterColorGrantCount').includes('data.starterLineColor')&&functionSource('inventoryCount').includes('starterColorGrantCount')&&functionSource('ownsStoreItem').includes('inventoryCount(itemId)>0'),'The starter color is not represented as owned inventory');
|
||||
assert(newPath.includes("activeLineColorItem()?.aurora?'aurora':null")&&newPath.includes('ownerId:currentPlayerId()')&&pathNormalization.includes('LINE_EFFECT_IDS.has(raw.lineEffect)'),'Aurora line presentation is not persisted on newly drawn paths or legacy paths');
|
||||
assert(appearance.includes("activeColor?.aurora?'aurora':'none'")&&appearance.includes('--player-line-color'),'Equipped color and Aurora are not synchronized to the presentation layer');
|
||||
assert(css.includes('.path.line-effect-aurora')&&css.includes('.gate-dot.line-effect-aurora'),'Aurora line and gate CSS presentation is missing');
|
||||
assert(css.includes('@media (prefers-reduced-motion:reduce)')&&css.includes('body.lightweight-rendering'),'Cosmetic effects do not provide reduced/lightweight rendering fallbacks');
|
||||
const reactionSource=functionSource('drawStyledReaction'),publishSource=functionSource('publishReactionAt'),realtime=read('realtime-server.js');
|
||||
for(const style of reactionEffects.map(item=>item.reactionStyle)){assert(reactionSource.includes(`style==='${style}'`),`Missing canvas presentation for emoji effect ${style}`);assert(realtime.includes(`'${style}'`),`Realtime server does not accept emoji effect ${style}`)}
|
||||
assert(publishSource.includes('style,x,y')&&publishSource.includes('sendOrQueueRealtimeReaction(reaction)')&&realtime.includes("REACTION_STYLES.has(message.style)?message.style:'classic'"),'Purchased emoji styles are not published and safely normalized');
|
||||
assert(functionSource('publishReaction',realtime).includes("style!=='classic'")&&functionSource('publishReaction',realtime).includes('active.playerId===client.playerId'),'Realtime authority does not prevent overlapping special emoji effects');
|
||||
const seeded=functionSource('seededStoreItemIds'),authoritative=functionSource('authoritativeStoreItemIds',read('server.js'));
|
||||
assert(seeded.includes('.slice(0,6)')&&seeded.includes('6-fixedTools.length')&&seeded.includes('item.scoreLens'),'Client shops do not offer six cursors and six non-cursor items including the Score Lens');
|
||||
assert(authoritative.includes('.slice(0,6)')&&authoritative.includes('6-fixedTools.length')&&authoritative.includes('item.scoreLens'),'Server shop authority does not match the twelve-item client inventory');
|
||||
assert(functionSource('renderStorePanel').includes("compact:true")&&functionSource('renderStorePanel').includes('if(category.compact)card.append(icon,buy)')&&css.includes('.store-compact-list{grid-template-columns:repeat(6'),'New shop cosmetics are not displayed in the compact cursor-style grid');
|
||||
assert(functionSource('starterLineColorForPlayer',read('server.js')).includes("createHash('sha256')"),'Server-backed players do not receive a stable starter color');
|
||||
console.log('v47.87 starter colors, Aurora line color, emoji effects, and cursor cleanup passed');
|
||||
15
test/v4786-effects-ux-smoke-test.js
Normal file
15
test/v4786-effects-ux-smoke-test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use strict';
|
||||
const {assert,app,css,read,functionSource}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),ids=new Set(catalog.map(item=>item.id));
|
||||
for(const id of['line-effect-prism','line-effect-comet','line-effect-neon','reaction-effect-rain'])assert(!ids.has(id),`Retired effect remains in catalog: ${id}`);
|
||||
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('REACTION_TARGET_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'Visual rendering is not capped at 30 FPS');
|
||||
assert(css.includes('.path.line-effect-aurora,.gate-marker.line-effect-aurora')&&css.includes('.gate-dot.line-effect-aurora')&&css.includes('!important')&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex')&&functionSource('startAuroraRgbAnimation').includes('AURORA_COLOR_INTERVAL')&&app.includes('AURORA_COLOR_INTERVAL=2000'),'Aurora is not driven by the curated two-second palette across lines and gates');
|
||||
const styled=functionSource('drawStyledReaction');
|
||||
assert(!styled.includes("style==='rain'")&&styled.includes('floatY=-18')&&!styled.includes('drawReactionFlash(context,50'),'Classic reaction is not reduced to a simple floating emoji or rain remains');
|
||||
assert(functionSource('drawLaserReaction').includes('spin*3.8'),'Laser center emoji is not rotating at high speed');
|
||||
const publish=functionSource('publishReactionAt');
|
||||
assert(publish.includes("style!=='classic'")&&publish.includes('activeLocalSpecialReactionUntil=expiresAt')&&publish.includes('sendOrQueueRealtimeReaction(reaction)'),'Client does not lock or share overlapping special emoji effects');
|
||||
assert(functionSource('publishReaction',read('realtime-server.js')).includes('active.playerId===client.playerId'),'Server does not reject overlapping special emoji effects');
|
||||
assert(functionSource('animatePurchasedItemToInventory').includes("fly.className='purchase-item-fly'")&&functionSource('animatePurchasedItemToInventory').includes('inventoryBtn.classList.add')&&functionSource('renderStorePanel').includes('purchaseFlyOrigin(icon)'),'Purchased-item appearance does not fly into the Item button');
|
||||
assert(!functionSource('makeBoard').includes('active-board-boundary')&&!css.includes('active-board-boundary'),'Retired selected-board orbit outline remains');
|
||||
console.log('v47.87 effect performance, reaction sharing, Aurora, and shop fly-in passed');
|
||||
20
test/v4787-user-cosmetic-realtime-smoke-test.js
Normal file
20
test/v4787-user-cosmetic-realtime-smoke-test.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
const {assert,app,css,html,read,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),server=read('server.js'),realtime=read('realtime-server.js');
|
||||
assert(buildMeta.APP_VERSION==='48.0'&&buildMeta.PACKAGE_VERSION==='48.0.0','v47.87 canonical version marker is missing');
|
||||
const aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(aurora?.lineColor==='#5fd8ff'&&aurora.aurora===true&&!('lineEffect'in aurora),'Aurora is not a shop-only line-color contract');
|
||||
assert(app.includes('AURORA_COLOR_INTERVAL=2000')&&app.includes("AURORA_RGB_PALETTE=Object.freeze(['79 235 255'")&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex=(auroraPaletteIndex+1)%AURORA_RGB_PALETTE.length'),'Aurora palette timing or deterministic selection is missing');
|
||||
const boardRender=functionSource('renderBoardNow');
|
||||
assert(boardRender.includes("activeLineColorItem()?.aurora===true")&&boardRender.includes("classList.toggle('line-effect-aurora',auroraGate)")&&css.includes('.gate-dot.line-effect-aurora')&&css.includes('.gate-marker.line-effect-aurora'),'Equipped Aurora does not color usable gates');
|
||||
assert(functionSource('normalizeSnapshot').includes('clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor')&&functionSource('globalForStorage').includes('lineColorStyle:migratedLineColorStyle(source)')&&functionSource('applyGlobalRecordToData').includes('normalizeEquippedCosmeticsInPlace(data)'),'Equipped appearance is not durable across storage reload/merge');
|
||||
assert(!app.includes('陳列は店ごとに異なります')&&!app.includes('便利な機能から6点を陳列しています。'),'Removed shop copy remains');
|
||||
assert(css.includes('.store-wallet{display:inline-flex')&&css.includes('white-space:nowrap')&&css.includes('.store-wallet b{display:inline'),'Store gem balance can still wrap before its number');
|
||||
assert(functionSource('drawPresenceLayer').includes('drawRemoteCursorGlyph(context,player,x,y)')&&functionSource('drawPresenceLayer').includes('drawRemotePlayerName(context,player,x,y)')&&!app.includes("viewport.addEventListener('pointerleave',hideRealtimeCursor"),'Nearby player cursor/name visibility is incomplete');
|
||||
assert(css.includes('.inventory-use.selected{')&&functionSource('updateInventoryItemView').includes("classList.toggle('selected',pressed&&active)"),'Equipped inventory action has no distinct color');
|
||||
assert(app.includes("const REACTION_EMOJIS=Object.freeze(['👍','🤩','🙏','🧠','🎉'])")&&realtime.includes("new Set(['👍','🤩','🙏','🧠','🎉'])")&&!app.includes("['👍','👉🏻'")&&!realtime.includes("['👍','👉🏻'"),'The pointing reaction was not replaced by star-struck');
|
||||
for(const oldName of['巨大絵文字','絵文字レーザー','絵文字オービット','絵文字花火','絵文字彗星'])assert(!app.includes(oldName),`Legacy emoji-prefixed item name remains: ${oldName}`);
|
||||
assert(!html.includes('debugAllItemsToggle')&&!app.includes('デバッグ使用可')&&app.includes("location.pathname.endsWith('/debug-items')")&&server.includes("pathname.endsWith('/debug-items')")&&functionSource('purchaseStoreItem').includes('if(!debug&&data.score<price)'),'Debug mode is not URL-only with normal purchasing');
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS 待機')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}'),'FPS label was not renamed');
|
||||
assert(functionSource('publishReactionAt').includes('sendOrQueueRealtimeReaction(reaction)')&&functionSource('sendOrQueueRealtimeReaction').includes('pendingRealtimeReactionMessages')&&functionSource('flushPendingRealtimeReactions').includes('realtimeSend(payload)')&&functionSource('handleRealtimeMessage').includes('flushPendingRealtimeReactions()'),'Emoji reactions are not shared reliably with nearby players');
|
||||
console.log('v47.87 requested cosmetics, persistence, debug URL, presence, and reaction sharing passed');
|
||||
25
test/v4788-time-attack-navigation-smoke-test.js
Normal file
25
test/v4788-time-attack-navigation-smoke-test.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
const fs=require('fs');
|
||||
const path=require('path');
|
||||
const root=path.join(__dirname,'..');
|
||||
const app=fs.readFileSync(path.join(root,'app.js'),'utf8');
|
||||
const html=fs.readFileSync(path.join(root,'index.html'),'utf8');
|
||||
const css=fs.readFileSync(path.join(root,'style.css'),'utf8');
|
||||
function assert(value,message){if(!value)throw new Error(message)}
|
||||
assert(html.includes('href="https://host.nishi.boats/~333/"')&&html.includes('333の部屋に戻る'),'The return link is missing');
|
||||
assert(!html.includes('id="selectedInfo"')&&!app.includes("querySelector('#selectedInfo')"),'The current board level remains in the top UI');
|
||||
assert(!app.includes('絵文字エフェクト')&&!html.includes('絵文字エフェクト'),'The reaction category still uses the retired label');
|
||||
assert(app.includes('TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:10,5:15,10:20})'),'Time-attack cooldown durations are incorrect');
|
||||
assert(app.includes('for(const minutes of TIME_ATTACK_MINUTES)data.timeAttackCooldowns[minutes]=cooldownEndsAt'),'Time-attack cooldown is not shared by every course');
|
||||
assert(app.includes("for(const value of['3','2','1'])")&&app.includes("showTimeAttackCountdownOverlay('Start','start')"),'The start countdown is incomplete');
|
||||
assert(app.includes("timeAttackBtn.classList.toggle('final-countdown',remaining<=30000&&remaining>0)")&&css.includes('.pill.time-attack.active.final-countdown'),'The right-side timer is not emphasized during the final 30 seconds');
|
||||
assert(html.includes('獲得ジェムの倍率UP')&&html.includes('獲得ジェムに応じて次の通り倍率が上昇します。')&&html.includes('<small>基礎累計</small>'),'The multiplier explanation is missing');
|
||||
assert(app.includes('return timeAttackMultiplier(run.baseCollected||0)')&&app.includes('reward.preTimeAward')&&app.includes('run.baseCollected'),'The multiplier implementation no longer matches the displayed explanation');
|
||||
assert(!html.includes('id="timeAttackResultCollected"')&&!html.includes('id="timeAttackResultMultiplier"')&&!html.includes('id="timeAttackResultBonus"'),'Removed result fields remain');
|
||||
assert(app.includes("'https://host.nishi.boats/~333/link-field/'"),'The result URL is missing');
|
||||
assert(css.includes('rgba(194,108,255,.42)')&&css.includes('#timeAttackCountdownOverlay'),'The purple multiplier panel or countdown styling is missing');
|
||||
console.log('v47.88 time-attack and navigation smoke test passed');
|
||||
|
||||
assert(html.includes('<title>LinkField/リンクフィールド</title>')&&app.includes('LinkField/リンクフィールド|タイムアタック'),'The LinkField title is missing');
|
||||
assert(!app.includes('キャンディローズ')&&!app.includes('ルビービーム')&&!app.includes('line-color-rose')&&!app.includes('line-color-ruby'),'Removed line colors remain in presentation code');
|
||||
assert(app.includes('let initialMeta=await randomUnsolvedMeta()')&&!app.includes('if(!restoreSavedCamera())centerMeta(initialMeta'),'Startup camera is not randomized to an unsolved board');
|
||||
34
test/v4791-server-startup-smoke-test.js
Normal file
34
test/v4791-server-startup-smoke-test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const http=require('http');
|
||||
const {parsePort,defaultPortCandidates,listenWithPortFallback}=require('../server');
|
||||
|
||||
function close(server){return new Promise(resolve=>server.close(()=>resolve()))}
|
||||
function listen(server,port=0){return new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',()=>resolve(server.address().port))})}
|
||||
|
||||
(async()=>{
|
||||
assert.equal(parsePort(undefined),8080);
|
||||
assert.equal(parsePort('0'),0);
|
||||
assert.equal(parsePort('4312'),4312);
|
||||
assert.throws(()=>parsePort('invalid'),/Invalid server port/);
|
||||
assert.deepEqual(defaultPortCandidates(8080).slice(0,3),[8080,3000,3001]);
|
||||
|
||||
const blocker=http.createServer((_req,res)=>res.end('occupied'));
|
||||
const occupiedPort=await listen(blocker);
|
||||
const fallbackServer=http.createServer((_req,res)=>res.end('LinkField'));
|
||||
const listening=await listenWithPortFallback(fallbackServer,{host:'127.0.0.1',preferredPort:occupiedPort,explicitPort:false,candidates:[occupiedPort,0]});
|
||||
assert.equal(listening.usedFallback,true);
|
||||
assert.notEqual(listening.port,occupiedPort);
|
||||
const response=await fetch(`http://127.0.0.1:${listening.port}/`);
|
||||
assert.equal(await response.text(),'LinkField');
|
||||
|
||||
const explicitServer=http.createServer();
|
||||
await assert.rejects(
|
||||
listenWithPortFallback(explicitServer,{host:'127.0.0.1',preferredPort:occupiedPort,explicitPort:true}),
|
||||
error=>error?.code==='EADDRINUSE'&&/already in use/.test(error.message)
|
||||
);
|
||||
|
||||
await close(fallbackServer);
|
||||
await close(blocker);
|
||||
console.log('LinkField v48.0 server startup fallback smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
40
test/v4792-apache-bridge-smoke-test.js
Normal file
40
test/v4792-apache-bridge-smoke-test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {renderApacheBridge,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
|
||||
|
||||
(async()=>{
|
||||
const rendered=renderApacheBridge(8080);
|
||||
assert.match(rendered,/RewriteRule \^api\/\(\.\*\)\$ http:\/\/127\.0\.0\.1:8080\/api\/\$1 \[P,L\]/);
|
||||
assert.match(rendered,/ws:\/\/127\.0\.0\.1:8080\/api\/realtime/);
|
||||
assert.match(rendered,/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
assert.equal((rendered.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
|
||||
assert.equal((rendered.match(new RegExp(END_MARKER,'g'))||[]).length,1);
|
||||
|
||||
const custom='Options -Indexes\n\n# custom rule\n';
|
||||
const first=replaceManagedBlock(custom,rendered);
|
||||
assert.match(first,/Options -Indexes/);
|
||||
assert.match(first,/# custom rule/);
|
||||
assert.match(first,/127\.0\.0\.1:8080/);
|
||||
const replaced=replaceManagedBlock(first,renderApacheBridge(4312));
|
||||
assert.match(replaced,/127\.0\.0\.1:4312/);
|
||||
assert.doesNotMatch(replaced,/127\.0\.0\.1:8080/);
|
||||
assert.equal((replaced.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
|
||||
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-apache-'));
|
||||
try{
|
||||
await fsp.writeFile(path.join(root,'.htaccess'),custom);
|
||||
const installed=await installApacheBridge({fsp,root,port:9123});
|
||||
assert.equal(installed.enabled,true);
|
||||
assert.equal(installed.written,true);
|
||||
const actual=await fsp.readFile(path.join(root,'.htaccess'),'utf8');
|
||||
assert.match(actual,/Options -Indexes/);
|
||||
assert.match(actual,/127\.0\.0\.1:9123/);
|
||||
const unchanged=await installApacheBridge({fsp,root,port:9123});
|
||||
assert.equal(unchanged.written,false);
|
||||
}finally{await fsp.rm(root,{recursive:true,force:true})}
|
||||
console.log('LinkField v48.0 Apache bridge smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
47
test/v4793-php-poll-bridge-smoke-test.js
Normal file
47
test/v4793-php-poll-bridge-smoke-test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const http=require('http');
|
||||
const {spawnSync}=require('child_process');
|
||||
const {createRealtimeHub}=require('../realtime-server');
|
||||
const {renderApacheBridge}=require('../server/apache-bridge');
|
||||
|
||||
(async()=>{
|
||||
const runtime=fs.readFileSync(require.resolve('../runtime-config.js'),'utf8');
|
||||
const app=fs.readFileSync(require.resolve('../app.js'),'utf8');
|
||||
const php=fs.readFileSync(require.resolve('../api-bridge.php'),'utf8');
|
||||
assert.match(runtime,/api-bridge\.php/);
|
||||
assert.match(runtime,/realtimeTransport:'http-poll'/);
|
||||
assert.match(app,/\/api\/realtime\/connect/);
|
||||
assert.match(app,/\/api\/realtime\/poll/);
|
||||
assert.match(app,/x-linkfield-authorization/);
|
||||
assert.match(php,/\.linkfield-port/);
|
||||
assert.match(php,/X-LinkField-Authorization/i);
|
||||
assert.match(renderApacheBridge(4312),/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
const phpCheck=spawnSync('php',['-l',require.resolve('../api-bridge.php')],{encoding:'utf8'});
|
||||
if(!phpCheck.error)assert.equal(phpCheck.status,0,phpCheck.stderr||phpCheck.stdout);
|
||||
|
||||
let now=1_000_000;
|
||||
const server=http.createServer();
|
||||
const hub=createRealtimeHub({server,authenticate:async()=>{throw new Error('unused')},getBoardInfo:async boardId=>boardId==='B0'?{solved:false,bounds:{minX:0,minY:0,maxX:1,maxY:1}}:null,now:()=>now});
|
||||
try{
|
||||
const first={playerId:'a'.repeat(24),name:'A'};
|
||||
const second={playerId:'b'.repeat(24),name:'B'};
|
||||
const a=hub.createPollingClient(first),b=hub.createPollingClient(second);
|
||||
const directClaim=await hub.claimBoard(first,'B0',a.presenceId);assert.equal(directClaim.ok,true);assert.equal(directClaim.claim.playerName,'A');
|
||||
const deniedClaim=await hub.claimBoard(second,'B0',b.presenceId);assert.equal(deniedClaim.ok,false);assert.equal(deniedClaim.reason,'occupied');
|
||||
assert.equal(a.messages[0].type,'ready');
|
||||
assert.equal(b.messages[0].type,'ready');
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},a.sequence);
|
||||
await hub.handlePollingMessage(second,b.presenceId,{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},b.sequence);
|
||||
now+=1000;
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'cursor',x:1,y:2,vx:0,vy:0,cursorStyle:'default'},a.sequence);
|
||||
now+=500;
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'reaction',id:'poll-r1',emoji:'🤩',style:'classic',x:1,y:2},a.sequence);
|
||||
const events=hub.pollPollingClient(second,b.presenceId,b.sequence);
|
||||
assert(events.messages.some(message=>message.type==='cursor'&&message.name==='A'));
|
||||
assert(events.messages.some(message=>message.type==='reaction'&&message.reaction?.emoji==='🤩'));
|
||||
assert.equal(hub.disconnectPollingClient(first,a.presenceId),true);
|
||||
}finally{hub.close()}
|
||||
console.log('LinkField v48.0 PHP bridge and HTTP realtime polling smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
59
test/v4794-background-deploy-smoke-test.js
Normal file
59
test/v4794-background-deploy-smoke-test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawnSync}=require('child_process');
|
||||
const BuildMeta=require('../build-meta');
|
||||
const service=require('../scripts/service-control');
|
||||
|
||||
(async()=>{
|
||||
assert.equal(BuildMeta.APP_VERSION,'48.0');
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-v4794-'));
|
||||
const publicDir=path.join(root,'public_html','link-field');
|
||||
const dataDir=path.join(root,'data');
|
||||
const logFile=path.join(root,'server.log');
|
||||
const serviceDir=path.join(root,'service');
|
||||
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,PORT:'0'};
|
||||
try{
|
||||
await service.deployPublicFiles(publicDir);
|
||||
for(const relative of ['index.html','app.js','runtime-config.js','api-bridge.php','assets','client']){
|
||||
assert.equal(fs.existsSync(path.join(publicDir,relative)),true,`${relative} was not deployed`);
|
||||
}
|
||||
const manifest=JSON.parse(await fsp.readFile(path.join(publicDir,'.linkfield-deployment.json'),'utf8'));
|
||||
assert.equal(manifest.version,'48.0');
|
||||
|
||||
const start=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(start.status,0,start.stderr||start.stdout);
|
||||
assert.match(start.stdout,/started in the background/i);
|
||||
assert.match(start.stdout,/command prompt is available again/i);
|
||||
const pidFile=path.join(serviceDir,'server.pid');
|
||||
const pid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
assert.equal(service.isProcessRunning(pid),true);
|
||||
const port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());
|
||||
assert.ok(Number.isSafeInteger(port)&&port>0);
|
||||
const response=await fetch(`http://127.0.0.1:${port}/api/cloud/status`);
|
||||
assert.equal(response.ok,true);
|
||||
const status=await response.json();
|
||||
assert.equal(status.sharedWorld,true);
|
||||
assert.equal(status.appVersion,'48.0');
|
||||
assert.equal(fs.existsSync(path.join(publicDir,'.htaccess')),true);
|
||||
|
||||
const secondStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(secondStart.status,0,secondStart.stderr||secondStart.stdout);
|
||||
assert.match(secondStart.stdout,/Replacing the running LinkField server/i);
|
||||
const replacementPid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
assert.notEqual(replacementPid,pid);
|
||||
assert.equal(service.isProcessRunning(pid),false);
|
||||
assert.equal(service.isProcessRunning(replacementPid),true);
|
||||
|
||||
const stop=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
|
||||
assert.equal(stop.status,0,stop.stderr||stop.stdout);
|
||||
assert.equal(service.isProcessRunning(replacementPid),false);
|
||||
}finally{
|
||||
spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
|
||||
await fsp.rm(root,{recursive:true,force:true});
|
||||
}
|
||||
console.log('LinkField v48.0 background deployment and service smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
54
test/v4795-single-world-only-smoke-test.js
Normal file
54
test/v4795-single-world-only-smoke-test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'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 {read,functionSource,root}=require('./helpers/app-source');
|
||||
|
||||
const app=read('app.js'),runtime=read('runtime-config.js'),html=read('index.html'),serverSource=read('server.js'),serviceSource=read('scripts/service-control.js');
|
||||
assert.match(runtime,/cloudApi:true/);
|
||||
assert.match(runtime,/singleSharedWorld:true/);
|
||||
assert(!app.includes('同期コード')&&!app.includes('端末のみ'),'Retired local/sync-code UI remains');
|
||||
assert(html.includes('class="shared-indicator"')&&html.includes('<span>共有</span>')&&!html.includes('共有 · 接続中')&&!html.includes('端末のみ'),'Shared state is not a fixed-size indicator');
|
||||
assert(!functionSource('setCloudStatus').includes('textContent')&&functionSource('setCloudStatus').includes('cloudBtn.dataset.state'),'Shared status still changes visible text or player names');
|
||||
assert(functionSource('init').indexOf('await initCloudSync({startup:true})')<functionSource('init').indexOf('refreshWorldView({rebuild:true'),'The local field is rendered before the server-authoritative world is adopted');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWorld===true")&&functionSource('fetchCurrentSharedWorldStatus').includes('status.appVersion===APP_VERSION')&&functionSource('initCloudSync').includes('resetClientToSingleSharedWorld()'),'Startup does not require the current single shared world');
|
||||
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes('realtimeSend')&&!functionSource('requestBoardClaim').includes('await waitForRealtimeReady()'),'Board input is not using direct claim with realtime fallback');
|
||||
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired local cross-tab synchronization remains');
|
||||
assert(serverSource.includes('INSTANCE_LOCK_FILE')&&serverSource.includes('Another LinkField server is already running'),'Server process lock is missing');
|
||||
assert(serverSource.includes("const PRODUCTION_DATA_DIR = path.resolve('/link-field/world')"),'Shared data is not fixed to /link-field/world');
|
||||
assert(!serverSource.includes('LINK_FIELD_WORLD_DIR')&&!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select a second shared-world directory');
|
||||
assert(serviceSource.includes('stopLegacyLinkFieldServers')&&serviceSource.includes('Replacing the running LinkField server')&&serviceSource.includes("fsp.unlink(path.join(publicDir,'.linkfield-port'))"),'Old server processes or stale bridge ports can survive deployment');
|
||||
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
(async()=>{
|
||||
const temp=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-single-world-'));
|
||||
const publicDir=path.join(temp,'public');
|
||||
const dataRoot=path.join(temp,'data-root');
|
||||
const env={...process.env,HOST:'127.0.0.1',PORT:'0',LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_APACHE_BRIDGE:'0'};
|
||||
const first=spawn(process.execPath,[path.join(root,'server.js')],{env,stdio:['ignore','pipe','pipe']});
|
||||
let firstErr='';first.stderr.on('data',chunk=>firstErr+=chunk);
|
||||
try{
|
||||
let port=0;
|
||||
for(let i=0;i<100;i++){
|
||||
try{port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());if(port)break}catch(_){ }
|
||||
if(first.exitCode!=null)throw new Error(firstErr||`First server exited with ${first.exitCode}`);
|
||||
await sleep(40);
|
||||
}
|
||||
assert(port>0,'First single-world server did not start');
|
||||
const status=await (await fetch(`http://127.0.0.1:${port}/api/cloud/status`)).json();
|
||||
assert.equal(status.singleWorld,true);
|
||||
assert.equal(status.revision,0);
|
||||
const second=spawnSync(process.execPath,[path.join(root,'server.js')],{env,encoding:'utf8',timeout:10000});
|
||||
assert.notEqual(second.status,0,'A second shared-world server started against the same data root');
|
||||
assert.match(`${second.stdout}\n${second.stderr}`,/already running/i);
|
||||
}finally{
|
||||
first.kill('SIGTERM');
|
||||
for(let i=0;i<50&&first.exitCode==null;i++)await sleep(20);
|
||||
if(first.exitCode==null)first.kill('SIGKILL');
|
||||
await fsp.rm(temp,{recursive:true,force:true});
|
||||
}
|
||||
console.log('LinkField v48.0 /link-field/world single shared world guards passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
62
test/v4797-shared-board-input-smoke-test.js
Normal file
62
test/v4797-shared-board-input-smoke-test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'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,read,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const app=read('app.js'),serverSource=read('server.js'),html=read('index.html'),css=read('style.css');
|
||||
assert(html.includes('class="shared-indicator"')&&html.includes('<span>共有</span>'),'Compact shared indicator is missing');
|
||||
assert(!html.includes('共有 · 接続中')&&!html.includes('共有成功')&&!html.includes('共有失敗'),'Variable shared-status labels remain in the visible UI');
|
||||
assert(css.includes('.shared-indicator')&&css.includes('bottom:calc(16px + var(--safe-bottom))'),'Shared indicator is not fixed above FPS');
|
||||
assert(!functionSource('setCloudStatus').includes('textContent'),'Shared indicator still changes visible text');
|
||||
assert(functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished states are omitted from cloud pending data');
|
||||
assert(!functionSource('noteCloudRow').includes("solved!==true"),'Unfinished states are still removed from the shared outbox');
|
||||
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes("type:'claim'"),'Board claim does not use the direct shared API with realtime fallback');
|
||||
assert(functionSource('markPendingClaimPointerReleased').includes('pending.released=true')&&functionSource('bindBoard').includes('finishReleased'),'A drag released while claim approval is pending is discarded');
|
||||
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired cross-tab board synchronization remains');
|
||||
assert(serverSource.includes('const state=JSON.parse(JSON.stringify(incoming));state.solved=false'),'Server still discards unfinished board paths');
|
||||
assert(serverSource.includes('unfinishedBoard')&&serverSource.includes('Board claim is required'),'Unfinished state writes are not protected by the active claim');
|
||||
|
||||
const port=25000+Math.floor(Math.random()*5000);
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-v4797-'));
|
||||
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='';child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
const base=`http://127.0.0.1:${port}`;
|
||||
const 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}}
|
||||
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
|
||||
function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed:1717,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
for(let index=0;index<100;index++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(30)}
|
||||
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(),meta=boardMeta(puzzle);
|
||||
let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,worldGeneration:'linkfield-single-world-20260801',nextId:1},metas:[meta],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}],deleted:[]})});
|
||||
assert.equal(result.response.status,200);assert.equal(result.body.revision,1);
|
||||
|
||||
const route=puzzle.solution[0],partial={paths:[{startGate:route.startGate,endGate:null,openGate:null,cells:route.cells.slice(0,3).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');
|
||||
|
||||
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});
|
||||
assert.equal(connected.response.status,200);
|
||||
const ready=connected.body.messages.find(message=>message.type==='ready');assert(ready?.presenceId,'Polling realtime connection did not return a presence id');
|
||||
const directClaim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,boardId:'B0'})});
|
||||
assert.equal(directClaim.response.status,200);assert.equal(directClaim.body.ok,true,'Direct claim API failed');
|
||||
const requestId='v4797-claim';
|
||||
const claimEnvelope=await request('/api/realtime/send',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,message:{type:'claim',requestId,boardId:'B0'},afterSequence:connected.body.sequence||0})});
|
||||
const claimResult=claimEnvelope.body.messages.find(message=>message.type==='claim-result'&&message.requestId===requestId);
|
||||
assert.equal(claimResult?.ok,true,'Claim through the cursor/reaction polling transport failed');
|
||||
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,200);assert.equal(result.body.revision,2);
|
||||
const pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});
|
||||
assert.equal(pulled.response.status,200);
|
||||
assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'Another player did not receive unfinished board progress');
|
||||
assert.equal(pulled.body.page.states.B0.solved,false);
|
||||
|
||||
console.log('LinkField v48.0 shared board input and progress smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
19
test/v4798-startup-version-retry-smoke-test.js
Normal file
19
test/v4798-startup-version-retry-smoke-test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const {read,functionSource}=require('./helpers/app-source');
|
||||
const app=read('app.js'),server=read('server.js'),service=read('scripts/service-control.js');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes('status.appVersion===APP_VERSION'),'Startup does not verify the running server release');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes('npm start'),'Version mismatch does not provide the correct server restart action');
|
||||
assert(functionSource('runStatusRetry').includes('statusRetryAction')&&functionSource('runStatusRetry').includes('await action()'),'Retry does not rerun the selected connection action');
|
||||
assert(!app.includes('復元用バックアップがありません。')&&!app.includes("onclick=retryRecovery"),'Startup retry can still enter obsolete backup recovery');
|
||||
assert(server.includes('appVersion:BuildMeta.APP_VERSION'),'Cloud status does not identify the server release');
|
||||
assert(functionSource('initCloudSync').includes('await activateSingleSharedClientCache()'),'Startup does not activate the new server-authoritative client cache epoch');
|
||||
assert(functionSource('initCloudSync').includes('stateIds:Object.keys(data.states)'),'Initial board state is omitted from the first shared-world commit');
|
||||
assert(functionSource('initCloudSync').includes("persistNow({skipCloud:true})"),'Initial shared board is read from IndexedDB before the new epoch is committed');
|
||||
assert(functionSource('mergeGlobalFields').includes('Math.max'),'Incremental shared-world pulls do not have a defined global merge path');
|
||||
assert(functionSource('pullCloudWorld').includes('cloudSyncing=false;setCloudStatus()')&&functionSource('pushCloudPending').includes('cloudSyncing=false;setCloudStatus()'),'Shared indicator can remain stuck in the syncing state');
|
||||
assert(functionSource('disconnectRealtimeForLifecycle').includes('keepalive:true'),'HTTP polling presence is not disconnected when the page closes');
|
||||
assert(read('realtime-server.js').includes("claim.ownerPresenceId === client.id")&&read('realtime-server.js').includes("releaseBoardClaim(boardId, 'disconnected')"),'Disconnected clients can retain board claims');
|
||||
|
||||
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('await stop({quiet:true})')<functionSource('start',service).indexOf('deployPublicFiles()'),'npm start does not replace a stale server before deployment');
|
||||
console.log('LinkField v48.0 startup version and retry regression test passed');
|
||||
64
test/v4800-shared-clear-economy-smoke-test.js
Normal file
64
test/v4800-shared-clear-economy-smoke-test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
'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,read,functionSource,loadBendPuzzle}=require('./helpers/app-source');
|
||||
|
||||
const app=read('app.js'),catalog=require('../store-catalog.json');
|
||||
const bindBoard=functionSource('bindBoard'),claimRequest=functionSource('requestBoardClaim'),removeClaim=functionSource('removeClaim'),claimPresentation=functionSource('applyClaimPresentationToBoard');
|
||||
assert(!bindBoard.includes('pointerover'),'Hover still starts board ownership');
|
||||
assert(bindBoard.includes("const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit')")&&bindBoard.indexOf('!endpointTarget&&!gateTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Ownership is requested before a knob/endpoint operation starts');
|
||||
assert(claimPresentation.includes("own?'プレイ中'"),'Own board badge is not labelled プレイ中');
|
||||
assert(!claimRequest.includes('toast(')&&!removeClaim.includes('toast('),'Board ownership still emits bottom notifications');
|
||||
assert(functionSource('applyCloudEnvelope').includes('applyPlayerEconomyEnvelope(result)'),'Clear push response does not update the local gem wallet');
|
||||
assert(functionSource('checkSolvedAndExpand').includes('data.states[b.id]=previous.state')&&functionSource('checkSolvedAndExpand').includes('for(let attempt=0;attempt<3&&!published;attempt++)'),'Unconfirmed clears can remain locally solved');
|
||||
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()'),'A non-solving player can generate transient expansion boards');
|
||||
const normalStarterIds=new Set(catalog.filter(item=>item.lineColor&&!item.aurora&&item.cost===5000).map(item=>item.id));
|
||||
assert(normalStarterIds.size>=2,'Normal starter color pool is missing');
|
||||
assert(app.includes("LINE_COLOR_ITEMS.filter(item=>item.effectLabel==='ラインカラー')"),'Client starter color pool includes premium colors');
|
||||
|
||||
const BendPuzzle=loadBendPuzzle();
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-v4800-'));
|
||||
const port=33000+Math.floor(Math.random()*2000),base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
let child=null,stderr='';
|
||||
function start(){
|
||||
stderr='';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']});
|
||||
child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
}
|
||||
async function stop(){if(!child)return;const target=child;child=null;target.kill('SIGTERM');await Promise.race([new Promise(resolve=>target.once('exit',resolve)),sleep(3000)]);if(target.exitCode==null)target.kill('SIGKILL')}
|
||||
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 ready(){for(let i=0;i<120;i++){try{const result=await request('/api/cloud/status');if(result.response.ok)return result.body}catch{}await sleep(30)}throw new Error(`Server did not start: ${stderr}`)}
|
||||
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:BendPuzzle.solverDifficulty(puzzle,1),targetLevel:1,seed,axis:puzzle.axis||'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate:route.startGate,endGate:route.endGate,cells:route.cells.map(cell=>[...cell])})),specialProgress:{crossings:[]},solved:true,expanded:false,rev:2,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
start();await ready();
|
||||
const sessions=[];for(let index=0;index<12;index++)sessions.push((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:`Player ${index}`})})).body);
|
||||
for(const session of sessions)assert(normalStarterIds.has(session.starterLineColor),`Premium or unknown starter color returned: ${session.starterLineColor}`);
|
||||
assert(new Set(sessions.map(session=>session.starterLineColor)).size>1,'Initial line color is not distributed across the normal color pool');
|
||||
const [alice,bob]=sessions;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,15,puzzle);
|
||||
let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));
|
||||
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});assert.equal(connected.response.status,200);const presenceId=connected.body.presenceId;
|
||||
const claim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId,boardId:'B0'})});assert.equal(claim.response.status,200);assert.equal(claim.body.ok,true);
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));assert.equal(result.body.clearEvents.length,1,'Clear was not authoritatively accepted');assert(result.body.player.earnedScore>0,'Clear response did not include earned gems');assert.equal(result.body.player.availableScore,result.body.player.earnedScore);
|
||||
const reward=result.body.player.earnedScore,clearRevision=result.body.revision;
|
||||
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');
|
||||
let pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Other player did not receive the clear');
|
||||
const playerState=await request('/api/player/state',{headers:auth(alice)});assert.equal(playerState.body.player.earnedScore,reward,'Gem wallet did not persist the clear reward');
|
||||
const stale=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:clearRevision,global:{nextId:1},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});assert.equal(stale.response.status,200,JSON.stringify(stale.body));
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'A stale unfinished update reverted a cleared board');
|
||||
const b1Puzzle=BendPuzzle.generatePuzzle([[0,0]],0x48000001,1,1,0),b1=boardMeta('B1',1,0x48000001,b1Puzzle);
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:stale.body.revision,global:{nextId:2},metas:[b1],states:[{id:'B1',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));const expansionRevision=result.body.revision;
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert(pull.body.page.metas.B1&&pull.body.page.states.B1,'New shared board disappeared before the next pull');
|
||||
await stop();start();const restarted=await ready();assert.equal(restarted.revision,expansionRevision);
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Cleared board reverted after server restart');assert(pull.body.page.metas.B1&&pull.body.page.states.B1,'New board disappeared after server restart');
|
||||
const economyAfterRestart=await request('/api/player/state',{headers:auth(alice)});assert.equal(economyAfterRestart.body.player.earnedScore,reward,'Gem wallet disappeared after server restart');
|
||||
console.log('LinkField v48.00 shared clear, claim release, expansion, and gem persistence smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(async()=>{await stop();fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
Loading…
Add table
Add a link
Reference in a new issue