This commit is contained in:
33333-33333 2026-07-29 20:52:11 +09:00
commit 091afc2b5c
77 changed files with 4405 additions and 8223 deletions

File diff suppressed because one or more lines are too long

9
.gitignore vendored
View file

@ -1,9 +0,0 @@
.tmp-edge-profile/
__pycache__/
*.pyc
# Local browser smoke/probe artifacts.
*probe*.html
*probe*.png
*smoke*.html
*smoke*.png

View file

@ -1,3 +1,5 @@
AddType application/manifest+json .webmanifest
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 0 seconds"
@ -21,15 +23,22 @@
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
<FilesMatch "\.(css|js|json|webmanifest|ico|png|webp|mp3|wav)$">
Header set Cache-Control "public, max-age=2592000, immutable"
</FilesMatch>
<FilesMatch "service-worker\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
<FilesMatch "\.(css|js|json|png|webp|mp3|wav)$">
Header set Cache-Control "public, max-age=2592000, immutable"
</FilesMatch>
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json text/plain
</IfModule>
# Never serve hidden server-data paths.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule (^|/)\. - [R=404,L]
</IfModule>

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

View file

@ -1,268 +0,0 @@
# 実績システム再監査レポート v39.16.78
対象: 全72実績、解除イベント、判定順序、進捗リセット、保存・復元、Undo/Redo、サーバー同期
方針: 前回重点対象だった「自分のずんち」「敵の敵は味方」だけでなく、これまで大きく取り上げていなかった実績を再度コード経路から監査した。明確な不具合は修正し、ゲームデザイン判断が必要な条件は変更していない。
## 1. 今回新たに確定・修正した不具合
### 1.1 「ミニマリスト」: プレイヤー設置物情報が保存されなくなる
`ミニマリスト` は「現在存在するプレイヤー設置物が10個以下」を判定するため、各アイテムの `_achievementPlayerPlaced` を必要とする。
旧実装では、この情報を保存する条件が実質的に `無計画都市` の未解除状態へ依存していた。そのため、`無計画都市` を先に解除した後にセーブ・ロードすると、既存アイテムのプレイヤー設置情報が失われ、`ミニマリスト` の物数判定が過少になる可能性があった。
**修正:** `無計画都市``ミニマリスト` のどちらかが未解除なら、プレイヤー設置情報を保存する。30秒削除用の設置時刻は従来どおり `無計画都市` に必要な場合だけ保存する。
---
### 1.2 「サウナ水風呂」: 高温開始時刻が更新され続ける
条件は「暑すぎる状態になった個体を15秒以内に寒すぎる状態へ移す」だが、旧実装は高温状態を評価するたびに開始時刻を現在時刻へ更新していた。
このため、長時間高温に置いた後でも、寒冷へ移した直前の評価時刻との差だけで15秒以内と判定され得た。
**修正:** 快適域外の高温状態へ**入った瞬間**だけ開始時刻を記録し、連続して高温にいる間は更新しない。高温状態を離れてから再び入った場合は新しい試行として時刻を記録する。
---
### 1.3 「抵抗器じゃない」: 致死感電した7体目がカウントされない
旧実装では感電ダメージを与えた後に実績カウントを行っていた。感電が致死になると対象は死亡済みになり、`recordWireShock` の生存チェックで除外されるため、7体目がその感電で死亡すると解除できない場合があった。
**修正:** 有効な感電を確認した時点で、ダメージ適用より先に実績カウントを行う。致死・非致死のどちらでも、その感電自体は1体分として扱う。
---
### 1.4 実績リセット後に隠れた途中進捗が残る
全実績リセット後も、World・たりない・アイテム本体に直接保持された一部の途中状態が残っていた。そのため、リセット直後に少ない追加操作だけで再解除できる実績があった。
**修正したリセット対象:**
- 同一個体への直接給餌回数・直接治療回数
- サウナ開始時刻・直前高温状態
- たりない保持開始時刻
- 喧嘩餅から喧嘩開始までの一時情報
- 粘着爆弾の受け渡し回数
- `無計画都市` 用の配置時刻
- 自然奴隷・自然王の世代進捗
`ミニマリスト` が現在の配置物を正しく識別するため、既存アイテムの「プレイヤーが配置した物か」という事実自体はリセットで消していない。
---
### 1.5 「監視カメラ」: Undo/Redo・地面変更が介入になっていない
配置・削除・給餌・つつく・射撃などは観察タイマーをリセットしていた一方、Undo、Redo、有効な地面変更は世界状態を変更する操作なのに観察タイマーをリセットしていなかった。
**修正:** 成功したUndo/Redo、および実際に地面種別が変わった操作を介入として記録する。
---
## 2. 前回重点対象の修正状態
### 「あぁ^~ たまらねぇぜ」
現在の条件は次の経路で成立する。
`おしり鋲でずんち蓄積 → おしり鋲を抜いて排出 → 排出ずんちが本人から一度離れる → バウンス柵等で反射・加速 → 本人へ帰還して致死衝突`
- 生成直後の本人との重なりは衝突扱いしない。
- 本人から一度離れた後に帰還した衝突だけを自己帰還として扱う。
- 非致死では解除しない。
- バウンス柵による反射・加速は資格を失わせない。
- 停止済み・プレイヤー保持済みの排出ずんちは対象外。
- 致死処理で本人の生存IDが解放されても、ダメージ前に所有者一致を保持するため解除可能。
### 「敵の敵は味方」
30体以上の状態で稼いだ連続数は、生存たりないが30体未満になった時点で0へ戻る。危険カテゴリのアイテムによるたりないへのダメージでも従来どおりリセットする。
前回ユーザー確認済みの「無傷」の範囲、戦闘ペア危険死の期限、機械化産業の履歴AND条件は現行仕様のまま変更していない。
---
## 3. これまで大きく取り上げていなかった実績の再監査結果
### 生態・繁殖
- `はじめての繁殖``ベビーブーム``大いなる母` は実際の繁殖成功イベントから接続されている。
- `天寿``水銀で天寿` は最終確定した死亡理由を受けた後に判定される。
- `25体全員が病気` は睡眠病だけを除外し、ずんち病・爆発病・喧嘩病を対象としている。
- `管理された楽園` は6日目以降から連続1ゲーム日を計測する。
- `安全第一` は25体以上を300ゲーム秒維持し、天寿以外の死亡で再計測する。天寿後に25体を下回れば人数条件によって停止する。
- `自給自足` は20体以上・複製機なし・直接給餌なしを600ゲーム秒維持するフィールド内条件。
- `雨宿り完了` は現行の雨天である小雨時に、パラソル・巣箱・土管の保護判定を使う。
- `町のお医者さん` は応急手当を使った回数ではなく、実際にHPが1以上回復した回数だけを累計する。
- `豊穣ヲ希求スル者` はラブ餅の影響下で成立した繁殖だけを累計する。
**追加の到達不能・イベント未接続は確認されなかった。**
### 実験・危険行動
- `6番目の大量絶滅` は同一Worldの10ゲーム秒ローリング区間で死亡50件。
- `豆野、殺ッカーやろうぜ!` は通常ボールの速度条件を満たした致死衝突から直接接続される。
- `ヘコヘコ中に着火` は着火処理が繁殖儀式状態を消す前に判定される。
- `下剤中の餓死` は死亡フレームの下剤状態を保持して判定する。
- `物理法則を下側に超越` はアイテム位置の実温度計算を走査しており、温度効果の重畳で到達可能。
- `おくすり手帳全埋め` は現行仕様どおり11種類を対象とし、鎖・王冠は除外する。
- `革命` は王の喧嘩死と、直前0.1ゲーム秒以内の奴隷による喧嘩ダメージを照合する。
- `火に油` は喧嘩餅を受けた個体が30ゲーム秒以内に参加する喧嘩開始を判定する。
- `五体満足` / `零体満足` は状態効果付与時と自然ステータス変化時の双方から再評価される。
- `混沌ヲ希求スル者` は喧嘩餅影響下の喧嘩開始だけを永続累計する。
- `命のバトン` は同じ生存中の粘着爆弾の受け渡し回数を保存・復元する。
- `情報化産業` は別々の2基盤を電線類で直接つなぎ、信号が実際に伝達された経路から解除される。
**追加の確定不具合は「抵抗器じゃない」の致死感電順序だけで、修正済み。**
### 建築・環境
- `物でいっぱい` はプレイヤー配置イベントを永続累計し、リンク類の配置も配置として数える。
- `清掃業者` はロボット掃除機による有効な清掃200回のフィールド内進捗で、現行ドキュメントと一致する。
- `潔癖症` は生存たりない0、生存アリ0、生存アイテムがロボット掃除機のみ、かつ条件を満たす稼働掃除機が存在する場合に成立する。死亡たりないは死亡直後に非表示となり定期圧縮されるため、画面上の死体残留との矛盾は確認されなかった。
- `メガロポリス` は100体・複製機10・草ベッド/巣箱/土管合計15の境界値で評価される。
- `砂場` は生存中のトイレ5個で成立する。
- `そこ公共空間だよ?` は公園フィールドで実際に地面変更が成立した時だけ解除する。
- `破格の工事費用` は壁時計1秒以内の有効な地面変更4回を数える。
**追加の確定不具合は「ミニマリスト」の保存メタデータだけで、修正済み。**
### 操作・継続
- `秘密のコレクション` は9スロットすべてに実セーブハッシュがあることを確認する。
- `何やってるの?` は空フィールドへのクリックだけを壁時計1秒内に4回数える。
- `見なかったことにしよう` は1回のUndo前後で死亡個体が10体以上減った場合に成立する。
- `やっぱなし` / `やっぱあり` は成功したUndo/Redoだけを永続累計する。
- `割とふわふわ` は共通の「つつく」対象判定からぬいぐるみを飛ばした場合に成立する。
- `狙撃手` は命中ではなく発砲333回を永続累計する。
- `スペックがたりない` は有効な計測FPSが10未満になった場合で、非表示タブ由来の異常なフレーム間隔を除外する。
- `毎日たりない観察` はローカル暦の日付単位の7日連続起動。
- `寝ろ` / `めっちゃたりない観察` は壁時計の連続経過で、現行仕様どおりタブ非表示時間も継続として扱う。
- `真・たりない観察` は自身以外の71実績が解除済みかを判定する。
**追加の確定不具合は「監視カメラ」の介入漏れと、全実績リセット後の隠れ進捗残留で、修正済み。**
---
## 4. 仕様確認が必要な曖昧点 — 現在は未変更
以下は実装が壊れているとは断定できず、表示文から複数の解釈が成立するため、回答を受けるまで条件変更を行っていない。
### Q1. 「ずんちどれい」「たりない王」の「出現」範囲
現行は**喧嘩戦績による自然変身だけ**で解除する。
- 鎖を付けて奴隷化 → 対象外
- 王冠で王化 → 対象外
- 奴隷同士から生まれた生来の奴隷 → 対象外
候補:
- **A: 現行どおり** — 喧嘩戦績による変身だけ
- **B: 人工付与以外** — 喧嘩戦績変身 + 生来の奴隷を対象。鎖・王冠は除外
- **C: 文字どおり全出現** — 鎖・王冠を含め、状態になった時点で解除
### Q2. 「末代までの恥」「華麗なる王族」の「○代連続」
現行は**同じ家系でなくても、自然変身した個体の世代番号が連番なら成立**する。例えば無関係な家系の2代・3代・4代・5代・6代に自然奴隷が1体ずついれば5代連続になる。
候補:
- **A: 現行どおり** — 世界内の世代番号の連続
- **B: 直系家系限定** — 親子関係を辿って同じ系譜で連続して状態が続く必要がある
Q1で生来の奴隷を対象にする場合、Q2の奴隷5代判定にもその出生を含めるかを合わせて決める必要がある。
### Q3. 「過保護」の「薬で治療」
現行は、薬カテゴリの直接使用なら効果の善悪を問わず治療回数に加算する。対象には応急手当だけでなく、睡眠薬、下剤、プロテイン、ニテロプ、弾薬、水銀、巨大化薬、小型化薬なども含まれる。
候補:
- **A: 現行どおり** — 薬カテゴリの直接使用3回
- **B: 治療系アイテムだけ** — 対象薬を明示的に限定
- **C: 実際に有益な回復が発生した使用だけ**
### Q4. 「無計画都市」の「削除する操作を累計30回」
現行は**操作回数ではなく、30秒以内に削除された対象物1個につき1回**進む。範囲削除1回で対象物を10個消した場合、最大10進む。
候補:
- **A: 現行どおり** — 削除した対象物数を数える
- **B: 削除コマンド単位** — クリック/範囲削除の1実行を1回として数える
- **C: 連続削除ジェスチャ単位** — 押している間の連続削除を1回として数える
### Q5. 「監視カメラ」の5分と「見るだけ」の意味
現行は**300ゲーム秒**。高速化すれば実時間5分より早く解除できる。今回、世界を変更するUndo/Redo・地面変更も介入として追加したが、UI操作、セーブ、速度変更、ポーズ、空クリックなど世界を直接変更しない操作はタイマーを止めない。
時間基準:
- **A: 現行どおり300ゲーム秒**
- **B: 実時間5分**
「見るだけ」の入力基準:
- **1: 現行どおり世界への介入だけでリセット**
- **2: ほぼすべてのユーザー操作でリセット**
例: `Q5 = A1` のように指定可能。
### Q6. 「抵抗器じゃない」のセーブ/Undo跨ぎ
現行の「同じ1本」はランタイム中の同一電線オブジェクト。セーブロードやUndo/RedoでWorldが再構築されると、16体までの途中人数は失われる。
候補:
- **A: 現行どおり** — 7体すべてを同じランタイム中に感電させる
- **B: 同じ保存上の電線なら継続** — セーブロード後も電線ごとの途中人数を保存する
### Q7. 「害虫駆除」の「アリを倒す」主体
現行は**アリが死亡した事実をすべて累計**する。プレイヤーの危険アイテムだけでなく、たりないとの戦闘、設備・環境など別要因で死亡しても1体として数える。
候補:
- **A: 現行どおり** — 死亡したアリをすべて数える
- **B: プレイヤー由来の撃破だけ**
- **C: プレイヤーまたはたりない陣営が倒した場合だけ**
---
## 5. 前回ユーザー確認済みの仕様 — 変更なし
以下は前回の確認により現行仕様を維持している。
- 戦闘ペア危険死: 第1死亡から第2死亡まで同一World内で明示的な時間制限なし。
- 機械化産業: ロープ・棒・ばねの配置履歴と信号作動履歴のAND。現在同時に存在する必要なし。
- 敵の敵は味方: 「無傷」は危険カテゴリ由来のたりないへのダメージをリセット対象とし、あらゆるダメージを禁止する意味ではない。
---
## 6. 検証結果
v39.16.78 に対して以下を実行し、すべて成功した。
- JavaScript全ファイル・監査スクリプトの `node --check`
- 全72実績の定義・カテゴリUI・同期監査
- プレイスタイル実績の境界値・リセット範囲・永続化・フック監査
- 「あぁ^~ たまらねぇぜ」専用の生成直後除外 / 離脱 / 帰還 / 致死・非致死回帰テスト
- サウナ開始時刻、実績リセット、監視介入、ミニマリスト保存情報、致死感電の追加回帰テスト
- 実績イベント統合監査
- サーバー状態・旧形式移行・並行同期監査
- 全体回帰監査 `scripts/regression_check.py`
- Python監査スクリプトのコンパイル確認
- `achievement_api.php` のPHP構文確認
結果はすべて `[OK]`。今回の再監査で新たに確認した**確定不具合は5系統で、すべて修正済み**。上記Q1Q7は仕様回答待ちのため、v39.16.78では現行挙動を維持している。
## Confirmed specification decisions (39.16.78)
- ずんちどれい / たりない王: birth-status appearances also qualify.
- 末代までの恥 / 華麗なる王族: only a direct qualifying parent-child lineage counts.
- 過保護: a treatment counts only when the direct use actually improves the health need.
- 無計画都市: one deletion operation counts once, even if area deletion removes multiple objects.
- 監視カメラ: requires 3 uninterrupted game days; gameplay operations are interventions.
- 抵抗器じゃない: partial wire progress remains nonpersistent across save/load and Undo.
- 害虫駆除: only player-caused ant deaths count.

112
AI_MAP.md
View file

@ -1,112 +0,0 @@
# tarinai_ AI map
Purpose: static Japanese browser simulation/game. Optimize context by reading this file only after `README.md`, then open the relevant subsystem files. For exact file expansion, use `scripts/ai_inventory.ps1` or `scripts/ai_inventory.py`.
## Architecture
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
- Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
- Command path: UI/input emits command objects; `js/command_dispatcher.js` routes tool, pointer, selection, save, reset, and UI intents.
- Persistence: save files are schema/codec/storage/coordinator modules; snapshot/history modules keep world restoration and stats consistent.
## Subsystem Map
- Core/bootstrap: `js/version.js`, `js/event_bus.js`, registries, `js/data.js`, `js/ground_types.js`, `js/health.js`, `js/main.js`, `js/debug_tools.js`, `js/perf_profiler.js`.
- Rendering/assets/audio: `js/assets.js`, `js/audio.js`, `js/render.js`, `js/tarinai_render.js`, `js/item_render_runtime.js`, `js/sound_pack.js`.
- Simulation phases: `js/sim_core.js`, `js/simulation*.js`, `js/world_update*.js`, `js/system_order.js`.
- World domain: `js/world*.js`, `js/weather_system.js`; covers state, camera/view, environment, combat/effects, placement/history, tools, family/social, ants, spatial budgets.
- Creature domain: `js/tarinai*.js`; covers state, identity, needs, actions, forced behavior, social life, item effects, disease/nests, movement/update pipeline, rendering.
- Item/structure domain: `js/item*.js`, `js/items.js`, `js/structures.js`, `js/structure_lifecycle.js`; covers definitions, spawning defaults, lifecycle, dynamic behavior, rendering.
- Physics/mechanics: `js/physics*.js`, `js/mechanical_system.js`, `js/constraint_system.js`, `js/collision_footprint_system.js`.
- UI/input: `js/ui*.js`, `js/text_catalog.js`, `js/command_dispatcher.js`; covers panels, tools, selected creature, logs, charts, family tree, mouse/touch, dialogs, labels.
- Family tree: `js/family_graph.js`, `js/ui_family_*.js`; data transform, layout, paths, async rendering, validation.
- Save/history: `js/save*.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`, `js/history_system.js`.
- Scripts: `scripts/generate_app_files.py`, `scripts/generate_item_icons.py`, `scripts/regression_check.py`, `scripts/ai_inventory.py`, `scripts/ai_inventory.ps1`.
## File Pattern Semantics
- `*_pipeline.js`: ordered runner/facade for update or lifecycle work.
- `*_step_*.js`: narrow pipeline step wrapper.
- `*_system.js`: domain service, phase service, or compatibility facade.
- `*_runtime.js`: stable runtime-facing facade around split modules.
- `*_registry.js`: definition registry and lookup/normalization helpers.
- `world_*`: world-owned behavior; `tarinai_*`: creature-owned behavior; `item_*`: item-owned behavior; `ui_*`: DOM/input/panel behavior.
- `assets/sprites/tarinai_*.webp`: creature visual state sprites; numeric prefix is stable asset ID/order.
- `assets/ui/tool_*`: tool palette icons; `assets/ui/favicon*`, `apple-touch-icon`, `ecology_*` are app/help icons.
- `assets/objects/*`: field object sprites for ants, zunchi, pushpin, oshibyo, genkotsu, plushie.
- `assets/sounds/voice_*`: creature voice samples; `shoot_*`, `major_damage_*`, `firecracker_*` are SFX.
## Task-Oriented Read Sets
- UI/layout bug: `index.html`, relevant `css/*.css`, `js/ui_bind.js`, `js/ui_layout_dialogs.js`, matching `js/ui_*.js`.
- Tool behavior: `js/command_dispatcher.js`, `js/world_tool_actions.js`, `js/item_registry.js`, relevant `js/item_*` or `js/tarinai_item_*`.
- Creature behavior: `js/tarinai.js`, `js/tarinai_update_pipeline.js`, relevant `js/tarinai_*`, `js/system_order.js`.
- World/environment/combat: matching `js/world_*`, `js/simulation_*`, `js/system_order.js`.
- Fire/burning behavior: `js/fire_runtime_system.js`, `js/item_dynamic_tool_system.js`, relevant item definitions, `js/world_combat_effects.js`, and nearby environment helpers.
- Performance/crowding: `js/perf_profiler.js`, `js/item_update_scheduler.js`, `js/tarinai_update_policy.js`, `js/constraint_system.js`, `js/world_spatial_budget.js`, and render/detail code.
- Save/load: `js/save_schema.js`, `js/save_codec.js`, `js/save_system.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`.
- Family tree: `js/world_family_social.js`, `js/family_graph.js`, `js/ui_family_*.js`.
- Static app/cache/version: `app_manifest.json`, `scripts/generate_app_files.py`, `service-worker.js`, `js/version.js`, `index.html`.
## Invariants
- Preserve `index.html` script order unless all dependent manifests/generated files are updated together.
- Keep `app_manifest.json`, `js/version.js`, `service-worker.js`, and cache query versions synchronized.
- Keep intentional global exports for compatibility with regression checks and browser runtime; remove public facades only after repo-wide caller checks.
- Prefer focused edits in the owning subsystem; split modules are intentional compatibility boundaries.
- Avoid reintroducing `simulationQualityTier` branches unless a real simulation-quality setting is restored; current quality controls are visual/detail controls.
- `High` display quality may include quality-preserving optimizations, but should not reduce simulation cadence, object limits, or visible fidelity in a noticeable way.
- Save format compatibility with old text saves is not required when intentionally shortening/replacing save hash text; keep current save/load internally consistent instead.
- Run `node --check js/*.js` and `python scripts/regression_check.py` for behavior changes.
## Current Maintenance Notes
This section records current boundaries only. Version-by-version cleanup history and completed work logs are intentionally omitted.
### Live Compatibility Surfaces
- Ordered global-IIFE loading is still part of the app contract; check repo-wide callers before removing bare globals or facade objects.
- Keep public event names with active listeners: `audio:play`, `selection:changed`, `tool:selected`, `world:phase`, `log:entry`, and `tool:placed`. Telemetry-only emit calls should stay removed.
- Save compatibility with old exported text is not required, but the current save/load round-trip must remain internally consistent.
- Tool icon ownership is `TOOL_DEFINITIONS.icon`; UI code converts asset paths for CSS variables. Avoid restoring CSS-local `url(...)` definitions.
- Shared geometry/display/domain helpers should stay in their owning helper modules unless a dedicated namespace migration is being done.
### Open Architecture Candidates
- Full namespace migration for behavior/action/needs/tool helpers. This requires rewriting many bare global call sites and updating regression contracts.
- Effect-specific spatial indexing, fuller delta-history storage, and high-speed collision partitioning. These need new indexes or semantics beyond cleanup.
- UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`.
- Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs.
## Current Feature Routing
- Achievement UI: `index.html` achievement dialog, `css/components.css` achievement rows/toast styles, and `js/achievements.js` DOM rendering.
- Achievement spell payload: `TarinaiAchievements.exportSpellState()`, `snapshot.g.s`, `save_system.js` spell export inclusion, and `save_codec.js` achievement metadata readers/writers.
- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles.
- Shared play: `js/seesaw_system.js`, tarinai action/need modules, placement preview, and item/structure definitions.
- Reset presets: `js/world_reset_presets.js`; UI reset selection is in `js/ui_layout_dialogs.js`; social archive reset/fallback routing touches `js/world_family_social.js`.
- Physics tool icons and previews: `js/ui_tools.js`, `js/render.js`, `js/placement_preview_system.js`, `js/physics_shape_editor_system.js`, and `js/item_tool_metadata.js`.
## Cleanup Candidate Audit - rechecked 2026-07-18
Do not delete these yet. This is a current worktree audit of unused, duplicated, stale, or low-meaning processes that should be reviewed before any removal or refactor.
### Stale Current-State Docs And Labels
- `BUILD_NOTE.txt` header still says schema 51 / payload v6, while `js/save_schema.js`, `js/achievements.js`, and `scripts/achievement_save_v6_audit.js` indicate schema 53 / payload v8.
### Audit Script Candidates
- `scripts/achievement_save_v6_audit.js` is named for v6 but asserts current schema 53 and achievement v8. Rename or fold into `scripts/achievement_audit.js` / `scripts/regression_check.py`.
- The many single-purpose `*_regression_audit.js` scripts appear to overlap the broad `scripts/regression_check.py` and achievement audits. Review for consolidation before deleting: `achievement_audio_tooltip_regression_audit.js`, `colony_crisis_regression_audit.js`, `effect_runtime_regression_audit.js`, `history_binary_regression_audit.js`, `long_session_runtime_regression_audit.js`, `non_tarinai_performance_regression_audit.js`, `object_render_cursor_regression_audit.js`, `offscreen_motion_performance_regression_audit.js`, `pathfinding_regression_audit.js`, `performance_5to7_regression_audit.js`, `performance_architecture_regression_audit.js`, `performance_cleanup_regression_audit.js`, `population_recovery_regression_audit.js`, `render_stack_regression_audit.js`, `reproduction_memory_sprite_regression_audit.js`, `reproduction_regression_audit.js`, `self_zunchi_regression_audit.js`, `social_interaction_regression_audit.js`, `tooltip_nail_colony_focus_regression_audit.js`, and `ui_grass_limits_regression_audit.py`.
- The invariant `node --check js/*.js` is weak on this Windows/PowerShell workspace because wildcard expansion does not behave like POSIX shells. Prefer a script-backed check or explicit file enumeration.
### Duplicated Helper/Glue Processes
- `const num = global.TarinaiCoreHelpers?.finiteOr || ...` is repeated in `js/geometry_helpers.js`, `js/mechanical_system.js`, `js/physics_projection_system.js`, `js/physics_world_system.js`, `js/placement_preview_system.js`, `js/seesaw_system.js`, and `js/signal_system.js`. Consider a load-order-safe local import pattern or just use `TarinaiCoreHelpers.finiteOr` directly where safe.
- `const clamp = global.TarinaiCoreHelpers?.clampNumber || ...` is repeated in `js/circuit_board_system.js`, `js/physics_world_system.js`, `js/seesaw_system.js`, and `js/signal_system.js`.
- Dialog close/backdrop wiring is partly consolidated in `js/world_placement_log.js`, but `js/world_placement_log.js` signal editor and `js/circuit_board_system.js` still manually wire cancel/close/backdrop handlers. Candidate for a small shared modal-close helper after checking editor-specific behavior.
- `finiteOrNull` in `js/snapshot_system.js` and `finiteNumberOrNull` in `js/save_codec.js` are nearly identical null-preserving numeric helpers. They are persistence-local today, but can be centralized if the save/snapshot boundary remains clear.

View file

@ -1 +0,0 @@
Current build metadata is defined in js/version.js and app_manifest.json.

View file

@ -1,191 +0,0 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.16.137",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",
"update_order": "js/system_order.js",
"manifest": "app_manifest.json",
"service_worker": "service-worker.js"
},
"read_strategy": {
"default": [
"README.md"
],
"architecture": [
"README.md",
"AI_MAP.md"
],
"exact_inventory": [
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All",
"python scripts/ai_inventory.py --all"
],
"machine_routing": [
"FILES.json"
]
},
"groups": [
{
"id": "root",
"patterns": [
".gitignore",
".htaccess",
"app_manifest.json",
"favicon.ico",
"index.html",
"README.md",
"AI_MAP.md",
"GAME_FEATURES.md",
"FILES.json",
"service-worker.js",
"achievement_api.php",
".achievement_data/.htaccess"
],
"meaning": "Repo/app shell metadata, server/static cache config, docs, manifest, entrypoint."
},
{
"id": "css",
"patterns": [
"css/*.css"
],
"meaning": "Base, layout, panel, component, and mobile responsive styling."
},
{
"id": "scripts",
"patterns": [
"scripts/*.js",
"scripts/*.py",
"scripts/*.ps1"
],
"meaning": "Generators, regression checks, version-specific audits, and token-aware AI inventory tooling."
},
{
"id": "core",
"patterns": [
"js/version.js",
"js/event_bus.js",
"js/domain_ids.js",
"js/registry_base.js",
"js/disease_registry.js",
"js/sound_pack.js",
"js/deterministic_helpers.js",
"js/item_tool_metadata.js",
"js/item_tool_definitions.js",
"js/item_visual_definitions.js",
"js/item_food_definitions.js",
"js/item_effect_definitions.js",
"js/item_registry.js",
"js/data.js",
"js/ground_types.js",
"js/health.js",
"js/input_mode_manager.js",
"js/main.js",
"js/math.js",
"js/perf_profiler.js",
"js/debug_tools.js",
"js/update_step_pipeline_runner.js",
"js/burn_motion_util.js"
],
"meaning": "Bootstrap, shared constants/helpers, registries, config, profiling/debug."
},
{
"id": "assets_runtime",
"patterns": [
"js/assets.js",
"js/audio.js",
"js/render.js",
"js/tarinai_render.js",
"js/item_render_runtime.js"
],
"meaning": "Image/audio loading and canvas rendering."
},
{
"id": "simulation",
"patterns": [
"js/sim_core.js",
"js/simulation*.js",
"js/system_order.js"
],
"meaning": "Simulation primitives, concrete systems, phase ordering."
},
{
"id": "world",
"patterns": [
"js/world*.js",
"js/weather_system.js"
],
"meaning": "World state/view/update/tools/environment/combat/family/social/ants."
},
{
"id": "physics",
"patterns": [
"js/physics*.js",
"js/mechanical_system.js",
"js/constraint_system.js",
"js/collision_footprint_system.js"
],
"meaning": "Geometry, collisions, constraints, mechanical item behavior."
},
{
"id": "items",
"patterns": [
"js/item*.js",
"js/items.js",
"js/structures.js",
"js/structure_lifecycle.js",
"js/circuit_board_system.js"
],
"meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering."
},
{
"id": "tarinai",
"patterns": [
"js/tarinai*.js",
"js/ants.js",
"js/seesaw_system.js"
],
"meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model."
},
{
"id": "save",
"patterns": [
"js/save*.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/history_system.js"
],
"meaning": "Snapshot schema/codec/storage/restore/history."
},
{
"id": "ui",
"patterns": [
"js/ui*.js",
"js/text_catalog.js",
"js/command_dispatcher.js",
"js/family_graph.js",
"js/achievements.js"
],
"meaning": "DOM UI, input, tools, selected panel, logs, charts, family tree, text labels, command routing."
},
{
"id": "assets",
"patterns": [
"assets/sprites/tarinai_*.webp",
"assets/ui/tool_*",
"assets/ui/favicon*",
"assets/ui/apple-touch-icon.png",
"assets/ui/ecology_*.webp",
"assets/objects/*",
"assets/sounds/*"
],
"meaning": "Creature sprites, UI/tool icons, field object images, voice/SFX samples."
}
],
"conventions": {
"*_pipeline.js": "ordered runner/facade",
"*_step_*.js": "pipeline stage wrapper",
"*_system.js": "domain service or compatibility facade",
"*_runtime.js": "stable runtime-facing facade",
"*_registry.js": "definition lookup/normalization"
}
}

View file

@ -1,93 +0,0 @@
# tarinai_ Game Features
`tarinai_` is an observation sandbox about small creatures called tarinai. The player shapes a field with food, medicine, furniture, hazards, machines, weather, and ground choices, then watches how the colony reacts. The game is about cause and effect: small changes in the environment can alter movement, stress, disease, relationships, reproduction, accidents, cleanup, and survival.
## Core Play
The player starts by choosing a field and ground, then places objects or uses direct tools on the colony. Tarinai continue acting on their own while the player observes, edits the environment, removes problems, or creates new ones. The colony panel, event log, selected-tarinai data, and family tree help explain what is happening without taking control away from the simulation.
The game supports quiet observation and chaotic experiments. A player can build a comfortable habitat with beds, food, grass, and cleaning tools, or create a dangerous machine field with rotators, fences, stones, pins, explosions, ants, and poison.
## Tarinai
Tarinai are autonomous creatures with HP, needs, stress, personality, age, family links, diseases, item effects, and behavior states. They choose actions from their current condition and surroundings instead of following a fixed script. Hunger can make them seek grass or food, sleepiness can make them find a bed, fear can make them flee, and social state can make them follow, fight, avoid, or approach another tarinai.
Each tarinai has individual tendencies. Aggressive tarinai fight more easily, social tarinai seek others more often, nervous tarinai react strongly to danger, and inherited traits can make children resemble their parents. Selecting a tarinai shows health, needs, stress, effects, relations, inventory, family information, and the reason for its current behavior.
Tarinai can be damaged by hazards, pushed by physics objects, carried by ants, attached to links, blocked by fences, affected by medicine, frightened by accidents, comforted by habitat items, or preserved when outside the active field. Some objects can become emotionally important, so losing them may cause panic.
## Food And Consumables
Grass is both food and habitat material. Tarinai can eat it, feel comfort near it, gather it for simple beds, and interact with it through ground and weather systems. Water can be drunk, can appear from rain, and can be generated or copied by tools.
Sweet food helps satisfy hunger and can support recovery in some cases. Love mochi makes reproduction behavior more likely for a while, while fight mochi pushes the colony toward conflict. Zunda juice clears item effects, making it useful after medicine or special consumables have changed a tarinai too much.
Medicine changes the simulation more aggressively than normal food. Sleep drugs push tarinai toward sleep or sleep disease, laxatives create repeated zunchi and reduce food benefit, protein makes a stronger permanent mode with lower disease risk, and niteropu makes a weaker permanent mode with higher disease risk. Ammo creates temporary speed and impact danger. Mystery drugs choose from eligible effects unpredictably. Mercury changes lifespan, while giant and dwarf drugs reshape size, movement, power, and ongoing damage risk.
## Habitat And Cleanup
Zunchi is waste, but it is also part of the ecology. It affects hygiene and disease risk and can fertilize grass. Beds and nest boxes give tarinai safer or better sleep options, reduce stress around resting areas, and help recovery. Balls give tarinai something to play with and can also become part of physics experiments.
Robot cleaners are mobile cleanup items. They scan for selected target types, pursue matching waste or corpses, and remove them on contact. The water hose gives the player direct cleanup control by washing dirt, zunchi, corpses, stains, and similar field problems. Signboards are editable objects that can be inspected after placement, useful for labeling areas or experiments.
## Physics And Machines
Physics items turn the field into a mechanical sandbox. Ropes connect two targets with a flexible link that can go slack but should not stretch freely. Rods create a rigid connection. Springs connect targets with elastic motion, allowing linked objects to pull back and oscillate.
Fences block movement and can be rotated for layout control. Gates can be opened or closed after placement. One-way fences allow passage from one direction while blocking the opposite direction. Bounce fences launch colliding targets more strongly than normal fences, which makes them useful for traps or kinetic experiments.
Rotators are editable mechanical shapes that spin and push nearby targets. Their shape and speed can create conveyors, crushers, traps, or chaotic collision fields. Reciprocators move back and forth along a line, so they can shove objects repeatedly. Poison blocks are custom hazard shapes that damage on contact.
## Electrical And Automation
Detectors, wires, insulated wires, and circuit boards are grouped in the Electrical tool category. Normal energized wire can hurt living targets on contact, while insulated wire is safer for incidental contact. Detectors can sense target ranges or time windows and send signals; clicking either dual-range track moves the nearer thumb directly to the clicked value. Circuit-board external input and output terminals are placed directly on the board edge, can be moved around all four sides, and appear dim in the editor until a world wire is actually connected.
## Danger Tools
Danger tools create immediate or ongoing pressure. Stones are heavy objects that become dangerous when dropped, thrown, or moved by machines. Genkotsu creates a large impact on the ground. Firecrackers cause explosions, with flame variants adding fire while respecting active-fire limits.
Pushpins can roll, lodge in tarinai, cause panic, and deal continuous damage. Oshibyo blocks zunchi while lodged and releases stored zunchi when removed. Shooting is a direct attack tool that can hit tarinai, ants, items, pins, balloons, or placed objects depending on the target under the aim point.
Ant nests introduce an enemy system. Ants search for tarinai, carry captured targets back to the nest, and can be shot or killed. Worker ants and queen ants have separate HP values, and ant counts are shown through colony statistics.
## Operation Tools
The observe tool selects tarinai or compatible objects and opens their data. The add tool introduces new tarinai. Poke applies a small interaction or push, while pinch lets the player drag tarinai and some objects. Copy duplicates placed items with their settings so setups can be repeated quickly. The operation palette also provides a dedicated snap control shared by placement and pinch movement; it cycles through Free, 20px, 40px, and 60px.
Undo and redo reverse or reapply placement and edit actions. Delete removes a specific object or stain, while area delete removes many nearby targets at once. These tools let the player repair a field after experiments without resetting the whole colony.
## Grounds And Environment
Fields define the playable space and boundary style. Grounds change the character of the colony: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style surfaces can affect movement, stress, comfort, and grass behavior. Ground choice matters because tarinai react not only to objects, but also to the surface they live on.
Weather changes the field over time. Rain can create water and affect comfort, shade can protect from weather or sun, and temperature changes how tarinai experience the environment. Grass, zunchi, splats, corpses, ant corpses, rain drops, and stains all feed into hygiene, disease, cleanup, rendering, and observation.
## Colony Statistics
The colony panel summarizes population, sick tarinai, juveniles, elders, deaths, mood, temperature, population limits, and object limits. Graph tabs show population, needs, and environment. Total ants are included in the environment statistics so active ant pressure can be read alongside other field signals.
The event panel records important simulation events such as birth, death, fights, accidents, weather, grass changes, and relation changes. The family tree shows parent-child structure, generations, inherited stats, personality tags, and family links.
## Save, Display, And Performance
Manual save and restore use browser storage. Reset returns to field selection and starts a new state. Service worker caching can keep the static app shell available after the first successful load when the browser supports it.
Display settings control effects, shadows, drawing detail, and quality choices for crowded scenes. Crowded fields are an intended part of the sandbox, so the game uses spatial lookup, scheduling, cached drawing, and incremental maintenance systems to keep large colonies and many objects playable.
## Achievement system
- Seventy-six achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, circuit-board signaling, and ant-related play styles.
- Locked rows reveal only the configured condition allowlist; all others show ``. There are no achievement tooltips. 母なる大地, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category.
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0100%, and the global achiever count is displayed beside it.
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
- `餌やりじいさん` requires 33 cumulative direct feedings; field resets do not reset this progress.
- `機械化産業` replaces the former detector-automation and link-craftsman achievements and unlocks only when both requirements are met.
- Local unlocks and persistent totals live in browser storage. Direct-feeding, Undo, and Redo totals persist across field resets. Cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress remain field-scoped where their conditions require it. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v3 achievement aggregate JSON while migrating older aggregate formats on read.
### Achievement spell persistence
- Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress.
- Loading a spell merges achievements and keeps the greater progress instead of erasing local achievement data.
- Restored unlocks are queued for the existing server recovery synchronization. Save slots remain world-only.
- Spell achievement metadata uses sparse binary encoding for unlock bits, persistent progress, minute-delta unlock timestamps, and sparse per-entity state. The current save and achievement payload formats are defined by `js/save_schema.js` and `js/achievements.js`.

View file

@ -1,61 +1,60 @@
# tarinai_
# たりない観察 v39.18.3
AI-first entrypoint for a static browser simulation/game. Keep this file small; read deeper docs only when needed.
## 実績システム
## Read Strategy
実績定義は `js/achievement_catalog.js` を唯一のカタログとして管理します。表示名、説明、カテゴリ、未解除時の公開設定、同期可能な進捗条件、累積上限は、クライアントと `achievement_api.php` が同じカタログから読み取ります。
- Start here for architecture, script order constraints, and where to look next.
- For game mechanics, UI, items, and tarinai behavior, read `GAME_FEATURES.md`.
- For subsystem work, read `AI_MAP.md` plus only the matching files.
- For exact file inventory, run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` or narrow it: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 ui`. Python equivalent: `python scripts/ai_inventory.py --all`.
- For machine-readable routing, read `FILES.json`.
- Avoid loading all assets into context; asset filenames are mostly self-describing and grouped by glob.
累積進捗の正規データは端末・タブ別の増加台帳です。表示用 `progress` に同じ累積値を重複保存しません。旧形式の重複カウンターと `synced` 一覧は読込時だけ移行し、現行形式では保存しません。
## Runtime Shape
「敵の敵は味方」は実績画面の「生態」カテゴリに掲載されます。
- Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots.
- Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases.
- UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.
- Generated/static app shell: `app_manifest.json`, `service-worker.js`, `FILES.json`, and `js/version.js` must stay version-aligned.
## ローカル保存
## File Routing
実績状態とサーバー資格情報は、通常保存、予備保存、同一タブ保存、IndexedDBへ世代付きで保存します。起動時は同じ世代の状態を追加方向に統合し、明示的な実績リセット時だけ世代を進めます。
- `index.html`: DOM shell, CSS/JS load order, panels/dialogs/canvas.
- `css/*.css`: base/layout/panel/components/mobile styling.
- `js/domain_ids.js`, `js/item_type_catalog.js`, `js/data.js`, `js/ground_types.js`: stable domain ids, current item-type save catalog, core config, field/ground definitions.
- `js/world*.js`: world state, view, update phases, tools, placement, combat, environment, family/social, ants.
- `js/tarinai*.js`: creature state, needs, actions, behavior, social life, item effects, update pipeline, rendering.
- `js/item*.js`, `js/structures*.js`: item registry/runtime/lifecycle/dynamic behavior/rendering plus structures.
- `js/simulation*.js`, `js/system_order.js`, `js/sim_core.js`: concrete update systems and phase ordering.
- `js/ui*.js`, `js/text_catalog.js`, `js/command_dispatcher.js`: DOM UI, input, selected panel, logs, charts, family tree, tools, labels.
- `js/save*.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`, `js/history_system.js`: persistence and history.
- `js/physics*.js`, `js/mechanical_system.js`, `js/constraint_system.js`, `js/collision_footprint_system.js`: geometry, mechanics, constraints, collisions.
- `assets/sprites/tarinai_*.webp`: creature state sprites.
- `assets/ui/tool_*`: tool palette icons; other `assets/ui/*` are app/help icons.
- `assets/objects/*`: field object images.
- `assets/sounds/*`: voice and SFX samples.
- `scripts/*`: generators, regression checks, and AI inventory.
v39.18.3は、v39.18.0以前の実績保存、旧ワールド実績メタデータ、実績スペルv9・v10、旧バイナリセーブを読み込めます。旧データは現行形式へ正規化され、新規保存には廃止済みフィールドを書きません。
## Conventions
## サーバー同期
- `*_pipeline` = ordered runner/facade.
- `*_step_*` = pipeline stage wrapper.
- `*_system` = domain service or compatibility facade.
- `*_runtime` = stable runtime-facing facade.
- `*_registry` = definition lookup/normalization.
- Many split files preserve regression contracts; do not collapse them casually.
同期プロトコルはv5です。
## Checks
- 解除状態と途中進捗を追加型で統合
- 同じ端末台帳の再送を重複加算しない
- リセット要求の再送を無害化
- 古いサーバーバックアップから新しいローカル世代を復旧
- 古い端末からリセット前の状態を復活させない
- プレイヤーIDと所有秘密鍵を耐久保存から復旧
- Syntax: `node --check js/*.js`
- Regression: `python scripts/regression_check.py`
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
共有達成率は、サーバーがゲームシミュレーションを再実行した検証値ではなく、クライアント端末の報告値です。UIとAPIでは「端末報告ベース」と明示します。
## 旧サーバーデータの自動移行
データディレクトリ、またはアプリ直下の `.achievement_data` に旧コンパクト形式の `state.json`スキーマ4がある場合、最初のAPI要求時に現行形式へ自動移行します。環境変数で新保存先を指定した場合や、v39.18.1が空の現行保存先を先に作成済みの場合も検出します。
- 原本は `legacy-backup/` へSHA-256付きで保存
- 旧プレイヤーIDから決定的なUUIDを生成
- プレイヤー数、解除実績、解除時刻、実績別集計を移行
- 旧データに存在しない途中進捗は0から開始
- `legacy-v4-migration.json` に完了件数を記録
- 中断時は次のAPI要求で冪等に再開
- 切替中にコピー元の旧 `state.json` が更新された場合も、内容ハッシュを再確認して追加方向で再取込
- ファイルサイズとmtimeが同じ更新もSHA-256比較で検出
旧ブラウザーに22文字のプレイヤーIDが残っている場合、クライアントはUUIDへ上書きする前に専用領域へ退避し、初回セッションで一度だけ新しい所有秘密鍵へ引き継ぎます。引継ぎ済みの旧IDを別の秘密鍵で再取得することはできません。
旧版から直接v39.18.3へ更新したブラウザーは自動引継ぎできます。v39.18.1を既に起動して旧22文字IDがブラウザーから失われている場合、サーバーデータだけから端末との本人対応を再構成することはできません。解除履歴と共有集計自体は正常に移行されます。
## 実績APIの保存先
本番環境では、PHP実行ユーザーが書き込める非公開ディレクトリを用意し、環境変数 `TARINAI_ACHIEVEMENT_DATA_DIR` に絶対パスを設定してください。未設定時は、HTTP公開ディレクトリ外の安定したユーザーデータ領域を使用します。
### Shared achievement percentage deployment
- Upload `achievement_api.php` and the hidden `.achievement_data` directory with the game.
- PHP must be enabled, and `.achievement_data` must be writable by the PHP process.
- On a static-only host, local unlocks still work; the global percentage is shown as unavailable.
- Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint.
- The current release tracks 76 achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return.
- `python3 scripts/achievement_server_state_audit.py` creates deterministic randomized achievement histories at runtime; it does not require or modify a production `state.json`.
## PHP組み込みサーバーでの起動
PHP組み込みサーバーは `.htaccess` を解釈しません。旧 `.achievement_data/state.json` には引継ぎ用IDが含まれるため、必ず同梱のルーターを使用してください。
```sh
./start_server.sh 8000
```
Windowsでは `start_server.bat 8000` を使用します。本番環境では引き続き `TARINAI_ACHIEVEMENT_DATA_DIR` をHTTP公開ディレクトリ外へ設定してください。

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"version": "39.16.137",
"version": "39.18.3",
"css": [
"css/base.css",
"css/layout.css",
@ -150,6 +150,7 @@
"js/text_catalog.js",
"js/ui.js",
"js/game_dialogs.js",
"js/achievement_catalog.js",
"js/achievements.js",
"js/ui_helpers.js",
"js/ui_log.js",

BIN
assets/ui/pwa-icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/ui/pwa-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -429,3 +429,23 @@ body.touch-mobile-ui .tool-tip-popover.mobile-selection-tip {
@media (max-width: 980px) {
.top-actions { overflow-x: auto; overflow-y: hidden; }
}
/* Mobile toolbar: keep every primary action reachable without hidden horizontal overflow. */
@media (max-width: 520px) {
.top-actions {
display: flex;
flex-wrap: wrap;
overflow: visible;
align-items: center;
}
.top-panel-search {
flex: 1 1 100%;
width: 100%;
min-width: 0;
}
.top-actions > .btn,
.top-actions > .top-menu,
.top-actions > .speed-menu {
flex: 0 1 auto;
}
}

View file

@ -4,14 +4,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.137" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.137" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.137" />
<link rel="stylesheet" href="css/base.css?v=39.16.137" />
<link rel="stylesheet" href="css/layout.css?v=39.16.137" />
<link rel="stylesheet" href="css/panel.css?v=39.16.137" />
<link rel="stylesheet" href="css/components.css?v=39.16.137" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.137" />
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.18.3" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.18.3" />
<link rel="manifest" href="manifest.webmanifest?v=39.18.3" />
<meta name="theme-color" content="#244d3a" />
<link rel="shortcut icon" href="favicon.ico?v=39.18.3" />
<link rel="stylesheet" href="css/base.css?v=39.18.3" />
<link rel="stylesheet" href="css/layout.css?v=39.18.3" />
<link rel="stylesheet" href="css/panel.css?v=39.18.3" />
<link rel="stylesheet" href="css/components.css?v=39.18.3" />
<link rel="stylesheet" href="css/mobile.css?v=39.18.3" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -56,14 +58,14 @@
</div>
<button id="saveBtn" class="btn">&#x30BB;&#x30FC;&#x30D6;</button>
<button id="ecologyBtn" class="btn ecology-top-btn" type="button">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</button>
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">&#x5B9F;&#x7E3E; <span id="achievementButtonCount">0/75</span></button>
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">&#x5B9F;&#x7E3E; <span id="achievementButtonCount">0/77</span></button>
<button id="resetBtn" class="btn danger reset-top-btn">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
<div id="topMenu" class="top-menu">
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">&#x2630;</button>
<div id="topMenuPanel" class="top-menu-panel hidden" role="menu" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">
<div id="topMenuPanel" class="top-menu-panel hidden" role="group" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">
<div id="soundMenu" class="sound-menu top-menu-sound">
<button id="soundBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x97F3; ON</button>
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="&#x97F3;">
<div id="soundPanel" class="sound-panel hidden" role="group" aria-label="&#x97F3;">
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>&#x52B9;&#x679C;&#x97F3;</span></label>
<div class="sound-panel-divider"></div>
<label><input type="checkbox" data-sound-category="voice"><span>&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x58F0;</span></label>
@ -73,7 +75,7 @@
</div>
<div id="visualSettingsMenu" class="sound-menu top-menu-sound top-menu-visual">
<button id="visualSettingsBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</button>
<div id="visualSettingsPanel" class="sound-panel hidden" role="menu" aria-label="&#x8868;&#x793A;&#x8A2D;&#x5B9A;">
<div id="visualSettingsPanel" class="sound-panel hidden" role="group" aria-label="&#x8868;&#x793A;&#x8A2D;&#x5B9A;">
<label><input id="visualEffectsToggle" type="checkbox" data-visual-setting="effects" data-visual-on="high" data-visual-off="off"><span>&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;</span></label>
<label><input id="visualShadowsToggle" type="checkbox" data-visual-setting="shadows" data-visual-on="high" data-visual-off="off"><span>&#x5F71;</span></label>
<div class="sound-panel-divider"></div>
@ -83,7 +85,7 @@
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="high"><span>&#x9AD8;</span></label>
</div>
</div>
<button id="creditsBtn" class="top-menu-item" type="button" role="menuitem">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button>
<button id="creditsBtn" class="top-menu-item" type="button">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button>
</div>
</div>
</div>
@ -147,7 +149,7 @@
<div class="colony-setting-section-head"><span>&#x6C17;&#x6E29;</span><strong id="temperatureValue">10&#x2103;</strong></div>
<div class="temperature-control" aria-label="&#x6C17;&#x6E29;&#x8ABF;&#x6574;">
<label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> &#x81EA;&#x52D5;</label>
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
<input id="temperatureSlider" class="temperature-slider" type="range" aria-label="&#x6C17;&#x6E29;" min="-100" max="120" step="1" value="10">
</div>
</section>
<section class="colony-setting-section colony-limit-section">
@ -214,7 +216,7 @@
<div class="achievement-dialog-head">
<div>
<div class="achievement-dialog-kicker">&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</div>
<h2 id="achievementsDialogTitle">&#x5B9F;&#x7E3E; <span id="achievementDialogCount">0 / 75</span></h2>
<h2 id="achievementsDialogTitle">&#x5B9F;&#x7E3E; <span id="achievementDialogCount">0 / 77</span></h2>
</div>
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="&#x5B9F;&#x7E3E;&#x3092;&#x9589;&#x3058;&#x308B;">&times;</button>
</div>
@ -305,175 +307,176 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.16.137" defer></script>
<script src="js/event_bus.js?v=39.16.137" defer></script>
<script src="js/domain_ids.js?v=39.16.137" defer></script>
<script src="js/registry_base.js?v=39.16.137" defer></script>
<script src="js/disease_registry.js?v=39.16.137" defer></script>
<script src="js/sound_pack.js?v=39.16.137" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.137" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.137" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.137" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.137" defer></script>
<script src="js/item_food_definitions.js?v=39.16.137" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.137" defer></script>
<script src="js/item_registry.js?v=39.16.137" defer></script>
<script src="js/data.js?v=39.16.137" defer></script>
<script src="js/ground_types.js?v=39.16.137" defer></script>
<script src="js/input_mode_manager.js?v=39.16.137" defer></script>
<script src="js/math.js?v=39.16.137" defer></script>
<script src="js/geometry_helpers.js?v=39.16.137" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.137" defer></script>
<script src="js/physics_helpers.js?v=39.16.137" defer></script>
<script src="js/placement_preview_system.js?v=39.16.137" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.137" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.137" defer></script>
<script src="js/mechanical_system.js?v=39.16.137" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.137" defer></script>
<script src="js/constraint_system.js?v=39.16.137" defer></script>
<script src="js/physics_world_system.js?v=39.16.137" defer></script>
<script src="js/physics_projection_system.js?v=39.16.137" defer></script>
<script src="js/circuit_board_system.js?v=39.16.137" defer></script>
<script src="js/signal_system.js?v=39.16.137" defer></script>
<script src="js/assets.js?v=39.16.137" defer></script>
<script src="js/audio.js?v=39.16.137" defer></script>
<script src="js/perf_profiler.js?v=39.16.137" defer></script>
<script src="js/display_helpers.js?v=39.16.137" defer></script>
<script src="js/render.js?v=39.16.137" defer></script>
<script src="js/sim_core.js?v=39.16.137" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.137" defer></script>
<script src="js/structures.js?v=39.16.137" defer></script>
<script src="js/item_type_catalog.js?v=39.16.137" defer></script>
<script src="js/items.js?v=39.16.137" defer></script>
<script src="js/item_type_initializers.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.137" defer></script>
<script src="js/item_update_policy.js?v=39.16.137" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.137" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.137" defer></script>
<script src="js/item_environment_hazard_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.137" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.137" defer></script>
<script src="js/item_runtime.js?v=39.16.137" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.137" defer></script>
<script src="js/memorial_bell_system.js?v=39.16.137" defer></script>
<script src="js/item_render_helpers.js?v=39.16.137" defer></script>
<script src="js/item_render_runtime.js?v=39.16.137" defer></script>
<script src="js/burn_motion_util.js?v=39.16.137" defer></script>
<script src="js/ants.js?v=39.16.137" defer></script>
<script src="js/health.js?v=39.16.137" defer></script>
<script src="js/tarinai.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.137" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.137" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.137" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.137" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.137" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.137" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.137" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.137" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.137" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.137" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.137" defer></script>
<script src="js/seesaw_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.137" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.137" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.137" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.137" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.137" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.137" defer></script>
<script src="js/tarinai_render.js?v=39.16.137" defer></script>
<script src="js/world.js?v=39.16.137" defer></script>
<script src="js/world_view.js?v=39.16.137" defer></script>
<script src="js/family_graph.js?v=39.16.137" defer></script>
<script src="js/world_reset_presets.js?v=39.16.137" defer></script>
<script src="js/world_family_social.js?v=39.16.137" defer></script>
<script src="js/impact_core_system.js?v=39.16.137" defer></script>
<script src="js/impact_response_system.js?v=39.16.137" defer></script>
<script src="js/collision_response_system.js?v=39.16.137" defer></script>
<script src="js/world_combat_effects.js?v=39.16.137" defer></script>
<script src="js/world_environment.js?v=39.16.137" defer></script>
<script src="js/world_temperature_system.js?v=39.16.137" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.137" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.137" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.137" defer></script>
<script src="js/world_ants_system.js?v=39.16.137" defer></script>
<script src="js/weather_system.js?v=39.16.137" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.137" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.137" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.137" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.137" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.137" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.137" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.137" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.137" defer></script>
<script src="js/simulation_systems.js?v=39.16.137" defer></script>
<script src="js/system_order.js?v=39.16.137" defer></script>
<script src="js/simulation.js?v=39.16.137" defer></script>
<script src="js/colony_situation_system.js?v=39.16.137" defer></script>
<script src="js/world_update.js?v=39.16.137" defer></script>
<script src="js/world_tool_actions.js?v=39.16.137" defer></script>
<script src="js/world_placement_log.js?v=39.16.137" defer></script>
<script src="js/world_event_effects.js?v=39.16.137" defer></script>
<script src="js/command_dispatcher.js?v=39.16.137" defer></script>
<script src="js/text_catalog.js?v=39.16.137" defer></script>
<script src="js/ui.js?v=39.16.137" defer></script>
<script src="js/game_dialogs.js?v=39.16.137" defer></script>
<script src="js/achievements.js?v=39.16.137" defer></script>
<script src="js/ui_helpers.js?v=39.16.137" defer></script>
<script src="js/ui_log.js?v=39.16.137" defer></script>
<script src="js/ui_selected.js?v=39.16.137" defer></script>
<script src="js/save_schema.js?v=39.16.137" defer></script>
<script src="js/snapshot_system.js?v=39.16.137" defer></script>
<script src="js/restore_coordinator.js?v=39.16.137" defer></script>
<script src="js/history_system.js?v=39.16.137" defer></script>
<script src="js/save_codec.js?v=39.16.137" defer></script>
<script src="js/save_storage.js?v=39.16.137" defer></script>
<script src="js/save_system.js?v=39.16.137" defer></script>
<script src="js/ui_tooltips.js?v=39.16.137" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.137" defer></script>
<script src="js/ui_ground.js?v=39.16.137" defer></script>
<script src="js/ui_tools.js?v=39.16.137" defer></script>
<script src="js/ui_input_shared.js?v=39.16.137" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.137" defer></script>
<script src="js/ui_input_touch.js?v=39.16.137" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.137" defer></script>
<script src="js/ui_bind.js?v=39.16.137" defer></script>
<script src="js/ui_charts.js?v=39.16.137" defer></script>
<script src="js/ui_family_data.js?v=39.16.137" defer></script>
<script src="js/ui_family_async.js?v=39.16.137" defer></script>
<script src="js/ui_family_layout.js?v=39.16.137" defer></script>
<script src="js/ui_family_paths.js?v=39.16.137" defer></script>
<script src="js/ui_family_render.js?v=39.16.137" defer></script>
<script src="js/main.js?v=39.16.137" defer></script>
<script src="js/debug_tools.js?v=39.16.137" defer></script>
<script src="js/version.js?v=39.18.3" defer></script>
<script src="js/event_bus.js?v=39.18.3" defer></script>
<script src="js/domain_ids.js?v=39.18.3" defer></script>
<script src="js/registry_base.js?v=39.18.3" defer></script>
<script src="js/disease_registry.js?v=39.18.3" defer></script>
<script src="js/sound_pack.js?v=39.18.3" defer></script>
<script src="js/deterministic_helpers.js?v=39.18.3" defer></script>
<script src="js/item_tool_metadata.js?v=39.18.3" defer></script>
<script src="js/item_tool_definitions.js?v=39.18.3" defer></script>
<script src="js/item_visual_definitions.js?v=39.18.3" defer></script>
<script src="js/item_food_definitions.js?v=39.18.3" defer></script>
<script src="js/item_effect_definitions.js?v=39.18.3" defer></script>
<script src="js/item_registry.js?v=39.18.3" defer></script>
<script src="js/data.js?v=39.18.3" defer></script>
<script src="js/ground_types.js?v=39.18.3" defer></script>
<script src="js/input_mode_manager.js?v=39.18.3" defer></script>
<script src="js/math.js?v=39.18.3" defer></script>
<script src="js/geometry_helpers.js?v=39.18.3" defer></script>
<script src="js/collision_footprint_system.js?v=39.18.3" defer></script>
<script src="js/physics_helpers.js?v=39.18.3" defer></script>
<script src="js/placement_preview_system.js?v=39.18.3" defer></script>
<script src="js/pin_attachment_system.js?v=39.18.3" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.18.3" defer></script>
<script src="js/mechanical_system.js?v=39.18.3" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.18.3" defer></script>
<script src="js/constraint_system.js?v=39.18.3" defer></script>
<script src="js/physics_world_system.js?v=39.18.3" defer></script>
<script src="js/physics_projection_system.js?v=39.18.3" defer></script>
<script src="js/circuit_board_system.js?v=39.18.3" defer></script>
<script src="js/signal_system.js?v=39.18.3" defer></script>
<script src="js/assets.js?v=39.18.3" defer></script>
<script src="js/audio.js?v=39.18.3" defer></script>
<script src="js/perf_profiler.js?v=39.18.3" defer></script>
<script src="js/display_helpers.js?v=39.18.3" defer></script>
<script src="js/render.js?v=39.18.3" defer></script>
<script src="js/sim_core.js?v=39.18.3" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.18.3" defer></script>
<script src="js/structures.js?v=39.18.3" defer></script>
<script src="js/item_type_catalog.js?v=39.18.3" defer></script>
<script src="js/items.js?v=39.18.3" defer></script>
<script src="js/item_type_initializers.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_support.js?v=39.18.3" defer></script>
<script src="js/item_update_policy.js?v=39.18.3" defer></script>
<script src="js/fire_runtime_system.js?v=39.18.3" defer></script>
<script src="js/robot_cleaner_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.18.3" defer></script>
<script src="js/item_environment_hazard_system.js?v=39.18.3" defer></script>
<script src="js/item_dynamic_system.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.18.3" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.18.3" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.18.3" defer></script>
<script src="js/item_runtime.js?v=39.18.3" defer></script>
<script src="js/structure_lifecycle.js?v=39.18.3" defer></script>
<script src="js/memorial_bell_system.js?v=39.18.3" defer></script>
<script src="js/item_render_helpers.js?v=39.18.3" defer></script>
<script src="js/item_render_runtime.js?v=39.18.3" defer></script>
<script src="js/burn_motion_util.js?v=39.18.3" defer></script>
<script src="js/ants.js?v=39.18.3" defer></script>
<script src="js/health.js?v=39.18.3" defer></script>
<script src="js/tarinai.js?v=39.18.3" defer></script>
<script src="js/tarinai_action_spec.js?v=39.18.3" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.18.3" defer></script>
<script src="js/tarinai_identity_social.js?v=39.18.3" defer></script>
<script src="js/tarinai_action_state.js?v=39.18.3" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.18.3" defer></script>
<script src="js/tarinai_item_effects.js?v=39.18.3" defer></script>
<script src="js/tarinai_needs_core.js?v=39.18.3" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.18.3" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.18.3" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.18.3" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.18.3" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.18.3" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.18.3" defer></script>
<script src="js/seesaw_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.18.3" defer></script>
<script src="js/tarinai_needs_items.js?v=39.18.3" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.18.3" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.18.3" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.18.3" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.18.3" defer></script>
<script src="js/tarinai_runtime.js?v=39.18.3" defer></script>
<script src="js/tarinai_render.js?v=39.18.3" defer></script>
<script src="js/world.js?v=39.18.3" defer></script>
<script src="js/world_view.js?v=39.18.3" defer></script>
<script src="js/family_graph.js?v=39.18.3" defer></script>
<script src="js/world_reset_presets.js?v=39.18.3" defer></script>
<script src="js/world_family_social.js?v=39.18.3" defer></script>
<script src="js/impact_core_system.js?v=39.18.3" defer></script>
<script src="js/impact_response_system.js?v=39.18.3" defer></script>
<script src="js/collision_response_system.js?v=39.18.3" defer></script>
<script src="js/world_combat_effects.js?v=39.18.3" defer></script>
<script src="js/world_environment.js?v=39.18.3" defer></script>
<script src="js/world_temperature_system.js?v=39.18.3" defer></script>
<script src="js/world_pathfinding_system.js?v=39.18.3" defer></script>
<script src="js/world_grass_placement_system.js?v=39.18.3" defer></script>
<script src="js/world_spatial_budget.js?v=39.18.3" defer></script>
<script src="js/world_ants_system.js?v=39.18.3" defer></script>
<script src="js/weather_system.js?v=39.18.3" defer></script>
<script src="js/item_update_scheduler.js?v=39.18.3" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.18.3" defer></script>
<script src="js/tarinai_update_policy.js?v=39.18.3" defer></script>
<script src="js/simulation_environment_system.js?v=39.18.3" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.18.3" defer></script>
<script src="js/simulation_effects_system.js?v=39.18.3" defer></script>
<script src="js/simulation_creature_system.js?v=39.18.3" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.18.3" defer></script>
<script src="js/simulation_ambient_system.js?v=39.18.3" defer></script>
<script src="js/simulation_systems.js?v=39.18.3" defer></script>
<script src="js/system_order.js?v=39.18.3" defer></script>
<script src="js/simulation.js?v=39.18.3" defer></script>
<script src="js/colony_situation_system.js?v=39.18.3" defer></script>
<script src="js/world_update.js?v=39.18.3" defer></script>
<script src="js/world_tool_actions.js?v=39.18.3" defer></script>
<script src="js/world_placement_log.js?v=39.18.3" defer></script>
<script src="js/world_event_effects.js?v=39.18.3" defer></script>
<script src="js/command_dispatcher.js?v=39.18.3" defer></script>
<script src="js/text_catalog.js?v=39.18.3" defer></script>
<script src="js/ui.js?v=39.18.3" defer></script>
<script src="js/game_dialogs.js?v=39.18.3" defer></script>
<script src="js/achievement_catalog.js?v=39.18.3" defer></script>
<script src="js/achievements.js?v=39.18.3" defer></script>
<script src="js/ui_helpers.js?v=39.18.3" defer></script>
<script src="js/ui_log.js?v=39.18.3" defer></script>
<script src="js/ui_selected.js?v=39.18.3" defer></script>
<script src="js/save_schema.js?v=39.18.3" defer></script>
<script src="js/snapshot_system.js?v=39.18.3" defer></script>
<script src="js/restore_coordinator.js?v=39.18.3" defer></script>
<script src="js/history_system.js?v=39.18.3" defer></script>
<script src="js/save_codec.js?v=39.18.3" defer></script>
<script src="js/save_storage.js?v=39.18.3" defer></script>
<script src="js/save_system.js?v=39.18.3" defer></script>
<script src="js/ui_tooltips.js?v=39.18.3" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.18.3" defer></script>
<script src="js/ui_ground.js?v=39.18.3" defer></script>
<script src="js/ui_tools.js?v=39.18.3" defer></script>
<script src="js/ui_input_shared.js?v=39.18.3" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.18.3" defer></script>
<script src="js/ui_input_touch.js?v=39.18.3" defer></script>
<script src="js/ui_input_mouse.js?v=39.18.3" defer></script>
<script src="js/ui_bind.js?v=39.18.3" defer></script>
<script src="js/ui_charts.js?v=39.18.3" defer></script>
<script src="js/ui_family_data.js?v=39.18.3" defer></script>
<script src="js/ui_family_async.js?v=39.18.3" defer></script>
<script src="js/ui_family_layout.js?v=39.18.3" defer></script>
<script src="js/ui_family_paths.js?v=39.18.3" defer></script>
<script src="js/ui_family_render.js?v=39.18.3" defer></script>
<script src="js/main.js?v=39.18.3" defer></script>
<script src="js/debug_tools.js?v=39.18.3" defer></script>
</body>
</html>

724
js/achievement_catalog.js Normal file
View file

@ -0,0 +1,724 @@
window.TARINAI_ACHIEVEMENT_CATALOG = {
"revision": "2026-07-25-achievement-cleanup-v1",
"completionistId": "true_tarinai_observer",
"categories": [
{
"id": "ecology",
"title": "生態"
},
{
"id": "population",
"title": "人口"
},
{
"id": "experiment",
"title": "実験"
},
{
"id": "construction",
"title": "建築"
},
{
"id": "operation",
"title": "操作"
},
{
"id": "other",
"title": "その他"
}
],
"medicineLedgerTypes": [
"first_aid",
"sedative",
"sleep_drug",
"laxative",
"protein",
"niteropu",
"ammo",
"mystery_drug",
"mercury",
"giant_drug",
"dwarf_drug"
],
"linkProgressTypes": [
"rope",
"rod",
"spring"
],
"additiveProgressCaps": {
"placementCount": 100,
"antKills": 100,
"birthCount": 3333,
"quickDeleteCount": 30,
"undoCount": 20,
"redoCount": 20,
"directFeedCount": 33,
"firstAidHeals": 50,
"fightMochiFightCount": 666,
"shotCount": 333,
"loveMochiBirthCount": 721,
"statsButtonPressCount": 30,
"totalDeathCount": 10000,
"manualTarinaiAddedCount": 100,
"robotCleanCount": 200
},
"definitions": [
{
"id": "first_birth",
"title": "はじめての繁殖",
"description": "たりないの繁殖を1回成功させる。",
"category": "population",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "birthCount",
"target": 1
}
},
{
"id": "natural_zunchi_slave",
"title": "ずんちどれい",
"description": "ずんちどれいが出現する。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "natural_tarinai_king",
"title": "たりない王",
"description": "たりない王が出現する。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "self_zunchi_death",
"title": "あぁ^~ たまらねぇぜ",
"description": "おしり鋲を抜いた時、自分のずんちで自爆する。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "zunchi_collision_death",
"title": "くちゃいのだ!",
"description": "ずんちとの衝突で死亡する個体が出現する。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "natural_zunchi_slave_5_generations",
"title": "末代までの恥",
"description": "5代連続でずんちどれい鎖なし",
"category": "population",
"showLockedDescription": true
},
{
"id": "natural_tarinai_king_3_generations",
"title": "華麗なる王族",
"description": "3代連続でたりない王王冠なし",
"category": "population",
"showLockedDescription": true
},
{
"id": "death_50_in_10_seconds",
"title": "6番目の大量絶滅",
"description": "10秒以内に50体のたりないが死亡する。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "birth_50_in_60_seconds",
"title": "ベビーブーム",
"description": "1分以内に30体のたりないが誕生する。",
"category": "population",
"showLockedDescription": true
},
{
"id": "great_mother_1000_births",
"title": "母なる大地",
"description": "たりないを繁殖で累計3333体誕生させる。",
"category": "population",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "birthCount",
"target": 3333,
"display": "percent",
"unit": "体"
}
},
{
"id": "lifespan_completed",
"title": "天寿",
"description": "たりない1体が天寿を全うする。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "soccer_ball_death",
"title": "豆野、殺ッカーやろうぜ!",
"description": "サッカーボールとの衝突で、たりない1体が死亡する。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "fight_pair_danger_kill",
"title": "喧嘩両成敗",
"description": "喧嘩中のたりないを、両方とも危険カテゴリのアイテムで倒す。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "all_non_sleep_diseased_25",
"title": "緊急事態宣言",
"description": "25体以上が存命し、フィールド上の全員がねむり病以外の病気にかかる。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "colony_happy",
"title": "管理された楽園",
"description": "コロニー状態をしばらく「幸福」にする。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "direct_feed_33",
"title": "餌やりじいさん",
"description": "手から直接33回餌を与える。",
"category": "ecology",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "directFeedCount",
"target": 33
}
},
{
"id": "ignite_during_birth_ritual",
"title": "燃え上がる恋",
"description": "たりないがヘコヘコ中に着火する。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "laxative_starvation",
"title": "内臓全部出た",
"description": "下剤の効果中に餓死する。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "idle_observer_5_minutes",
"title": "監視カメラ",
"description": "3ゲーム日、操作せず画面を見るだけ",
"category": "operation",
"showLockedDescription": false
},
{
"id": "placed_objects_100",
"title": "物でいっぱい",
"description": "配置物を累計100個置く。",
"category": "construction",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "placementCount",
"target": 100
}
},
{
"id": "mechanized_industry",
"title": "機械化産業",
"description": "検知器の信号で接続先を作動させ、ロープ・棒・ばねをそれぞれ1本以上設置する。",
"category": "construction",
"showLockedDescription": false,
"progress": {
"type": "mechanized"
}
},
{
"id": "safe_colony_25_5_minutes",
"title": "安全第一",
"description": "25体以上いる状態をゲーム内5分間維持し、その間寿命以外で死亡者を出さない。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "ants_alive_25",
"title": "アリの惑星",
"description": "生存しているアリが25体に到達する。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "ants_killed_100",
"title": "害虫駆除",
"description": "アリを累計100体倒す。",
"category": "experiment",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "antKills",
"target": 100
}
},
{
"id": "secret_collection_9_slots",
"title": "秘密のコレクション",
"description": "セーブスロット9つをすべて埋める。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "pause_spam_4_in_1_second",
"title": "何やってるの?",
"description": "何もない部分を1秒間に4回連打する。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "continuous_play_1_hour",
"title": "めっちゃたりない観察",
"description": "1時間連続プレイする。",
"category": "other",
"showLockedDescription": true
},
{
"id": "below_absolute_zero_item",
"title": "物理法則を下側に超越",
"description": "温度計算されるアイテムを1つ絶対零度を下回る温度にする。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "undo_mass_revival",
"title": "見なかったことにしよう",
"description": "一度のUndoで10体以上の死亡をなかったことにする。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "robot_cleaner_100",
"title": "清掃業者",
"description": "ロボ掃除機にずんち・死骸・汚れを累計200個処理させる。",
"category": "construction",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "robotCleanCount",
"target": 200,
"display": "count",
"unit": "個"
}
},
{
"id": "held_30_seconds",
"title": "空中生活",
"description": "同じたりないを30秒間つまみ続ける。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "minimalist_happy",
"title": "ミニマリスト",
"description": "プレイ開始6日目以降、配置物を10個以下に抑えてコロニー状態を「幸福」にする。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "overprotective",
"title": "過保護",
"description": "同じたりないに手から10回餌を与え、3回以上薬で治療する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "self_sufficient",
"title": "自給自足",
"description": "複製機がない状態で、20体以上を直接給餌せず10分間維持する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "unplanned_city_30",
"title": "無計画都市",
"description": "配置後30秒以内に削除する操作を累計30回行う。",
"category": "construction",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "quickDeleteCount",
"target": 30
}
},
{
"id": "sauna_cold_plunge",
"title": "サウナ水風呂",
"description": "暑すぎる状態になったたりないを、15秒以内に寒すぎる状態にする。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "rain_shelter_all",
"title": "雨宿り完了",
"description": "雨天時、20体以上の全員をパラソル・巣箱・土管で雨宿りさせる。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "medicine_ledger_all",
"title": "おくすり手帳全埋め",
"description": "全種類の薬を少なくとも1回使用する。",
"category": "ecology",
"showLockedDescription": false,
"progress": {
"type": "set",
"key": "medicineTypes",
"required": "medicineLedgerTypes"
}
},
{
"id": "mercury_lifespan",
"title": "不老不死まであと一歩",
"description": "水銀を使用したたりないが天寿を全うする。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "enemy_enemy_friend",
"title": "敵の敵は味方",
"description": "たりない30体以上を無傷で保ちながら、危険アイテムでアリを10体倒す。",
"category": "ecology",
"showLockedDescription": true
},
{
"id": "revolution",
"title": "革命",
"description": "ずんちどれいが、たりない王を喧嘩で死亡させる。",
"category": "experiment",
"showLockedDescription": true
},
{
"id": "fuel_to_fire",
"title": "火に油",
"description": "けんか餅を与えたたりないが30秒以内に喧嘩を始める。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "undo_20",
"title": "やっぱなし",
"description": "Undoを累計20回成功させる。",
"category": "operation",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "undoCount",
"target": 20
}
},
{
"id": "redo_20",
"title": "やっぱあり",
"description": "Redoを累計20回成功させる。",
"category": "operation",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "redoCount",
"target": 20
}
},
{
"id": "eternal_history_generation_10",
"title": "悠久の歴史",
"description": "世代が10に到達する。",
"category": "population",
"showLockedDescription": true
},
{
"id": "king_full_satisfaction",
"title": "五体満足",
"description": "たりない王1個体にプロテインと巨大薬の効果を同時に付与する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "slave_zero_satisfaction",
"title": "零体満足",
"description": "ずんちどれい1個体にンイテロプと矮小薬の効果を同時に付与する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "town_doctor_50",
"title": "町のお医者さん",
"description": "救急薬を累計50回使用し、体力を回復させる。",
"category": "ecology",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "firstAidHeals",
"target": 50,
"display": "count",
"unit": "回"
}
},
{
"id": "poke_plushie_fling",
"title": "割とふわふわ",
"description": "ぬいぐるみを「つつく」で弾き飛ばす。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "chaos_seeker_666_fights",
"title": "混沌ヲ希求スル者",
"description": "けんか餅の影響下で喧嘩を累計666回発生させる。",
"category": "experiment",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "fightMochiFightCount",
"target": 666,
"display": "percent",
"unit": "回"
}
},
{
"id": "clean_freak_robot_only",
"title": "潔癖症",
"description": "ロボット掃除機の設定全項目にチェックを入れて稼働させ、フィールド上にロボット掃除機以外を存在させない。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "true_tarinai_observer",
"title": "真・たりない観察",
"description": "これ以外の全ての実績を達成する。",
"category": "other",
"showLockedDescription": false
},
{
"id": "sniper_333_shots",
"title": "狙撃手",
"description": "累計333発撃つ。",
"category": "operation",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "shotCount",
"target": 333
}
},
{
"id": "megalopolis",
"title": "メガロポリス",
"description": "複製機を10個以上、干草寝床・巣箱・土管を合計15個以上設置し、たりないを100体以上生存させる。",
"category": "construction",
"showLockedDescription": true
},
{
"id": "fertility_seeker_721_love_births",
"title": "豊穣ヲ希求スル者",
"description": "へこ餅の影響下で繁殖を721回成功させる。",
"category": "population",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "loveMochiBirthCount",
"target": 721,
"display": "percent",
"unit": "回"
}
},
{
"id": "low_fps_single_digit",
"title": "スペックがたりない",
"description": "fpsを1桁にする。",
"category": "other",
"showLockedDescription": true
},
{
"id": "continuous_play_24_hours",
"title": "寝ろ",
"description": "24時間連続でプレイする。",
"category": "other",
"showLockedDescription": true
},
{
"id": "sandbox_five_toilets",
"title": "砂場",
"description": "トイレの砂をフィールドに5個設置する。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "park_ground_changed",
"title": "そこ公共空間だよ?",
"description": "公園の地面を勝手に変更する。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "ground_change_4_in_1_second",
"title": "破格の工事費用",
"description": "地面を変えすぎて破産する。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "ant_nest_without_tarinai",
"title": "アリ観察",
"description": "アリの巣があるが、たりないがいない。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "sticky_bomb_15_passes",
"title": "命のバトン",
"description": "粘着ボムを爆発までに15回以上パスする。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "daily_play_7_days",
"title": "毎日たりない観察",
"description": "7日連続でプレイする。",
"category": "other",
"showLockedDescription": true,
"progress": {
"type": "value",
"key": "dailyPlayStreak",
"target": 7,
"display": "percent",
"unit": "日"
}
},
{
"id": "wire_shock_7_tarinai",
"title": "抵抗器じゃない",
"description": "電流が流れている1本の電線で7体のたりないを感電させる。",
"category": "experiment",
"showLockedDescription": false
},
{
"id": "information_industry",
"title": "情報化産業",
"description": "基盤同士を電線類でつなぎ、信号を流す。",
"category": "construction",
"showLockedDescription": false
},
{
"id": "strength_in_numbers",
"title": "数こそ力",
"description": "生存しているたりないが100体に到達する。",
"category": "population",
"showLockedDescription": false
},
{
"id": "elite_few",
"title": "少数精鋭",
"description": "たりないが25体以下の状態を5日間維持する。",
"category": "population",
"showLockedDescription": true
},
{
"id": "zunchi_overflow",
"title": "踩屎感",
"description": "たりないが20体以上いる状態で、ずんちの数がたりないの数を上回る。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "comfortable_beds",
"title": "快適な寝床",
"description": "生存している全てのたりないに行き渡る数の寝床を用意する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "stone_pillow",
"title": "石に枕す",
"description": "たりないが30体以上いる状態で、寝床充足率を0%にする。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "across_seasons",
"title": "季節を越えて",
"description": "春夏秋冬の四季を一巡する。",
"category": "other",
"showLockedDescription": false
},
{
"id": "favorite_one",
"title": "推し",
"description": "ある個体をお気に入りに登録する。",
"category": "ecology",
"showLockedDescription": false
},
{
"id": "statistician",
"title": "統計学者",
"description": "統計の個体数・欲求・環境・グラフ表示切替ボタンを累計30回押す。",
"category": "operation",
"showLockedDescription": false,
"progress": {
"type": "counter",
"key": "statsButtonPressCount",
"target": 30,
"display": "count",
"unit": "回"
}
},
{
"id": "well_informed",
"title": "情報通",
"description": "プッシュ通知をONにする。",
"category": "operation",
"showLockedDescription": false
},
{
"id": "lively_making",
"title": "賑わいづくり",
"description": "「たりないを追加」で100体追加する。",
"category": "population",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "manualTarinaiAddedCount",
"target": 100,
"display": "count",
"unit": "体"
}
},
{
"id": "memento_mori",
"title": "多数の死は統計上の数字でしかない",
"description": "たりないが合計10000体死亡する。",
"category": "population",
"showLockedDescription": true,
"progress": {
"type": "counter",
"key": "totalDeathCount",
"target": 10000,
"display": "percent",
"unit": "体"
}
},
{
"id": "mad_scientist",
"title": "マッドサイエンティスト",
"description": "異なる10匹のたりないに怪しい薬を投与する。",
"category": "experiment",
"showLockedDescription": false,
"progress": {
"type": "unique",
"key": "mysteryDrugTarinaiIds",
"target": 10,
"display": "count",
"unit": "匹"
}
}
]
};

File diff suppressed because it is too large Load diff

View file

@ -395,13 +395,23 @@
result.boardId = board.id || "";
result.direction = port.direction;
result.signalActive = port.direction === "output" ? Boolean(board._circuitOutputState?.[port.id]) : Boolean(board._circuitInputState?.[port.id]);
result._signalDetectorSources = port.direction === "output"
? [...(board._circuitOutputDetectorSources?.[port.id] || [])]
: [...(board._circuitInputDetectorSources?.[port.id] || [])];
return result;
}
function evaluate(board, inputValues = null) {
function evaluate(board, inputValues = null, inputProvenance = null) {
const config = ensureConfig(board);
const inputs = Object.create(null);
for (const port of config.inputs) inputs[port.id] = Boolean(inputValues ? inputValues[port.id] : board?._circuitInputState?.[port.id]);
const inputDetectorSources = Object.create(null);
for (const port of config.inputs) {
inputs[port.id] = Boolean(inputValues ? inputValues[port.id] : board?._circuitInputState?.[port.id]);
const rawSources = inputProvenance ? inputProvenance[port.id] : board?._circuitInputDetectorSources?.[port.id];
inputDetectorSources[port.id] = inputs[port.id]
? [...new Set(Array.isArray(rawSources) ? rawSources.filter(Boolean) : [])]
: [];
}
const parent = new Map();
const rank = new Map();
@ -511,18 +521,74 @@
return Boolean(value);
}
const netDetectorSources = new Map();
const nodeDetectorSources = Object.create(null);
const visitingSourceNodes = new Set();
const visitingSourceNets = new Set();
const unionSources = (...lists) => [...new Set(lists.flatMap(list => Array.isArray(list) ? list : []))];
function driverDetectorSources(token) {
if (token.startsWith("i:")) {
const id = token.slice(2);
return inputs[id] ? inputDetectorSources[id] || [] : [];
}
const match = /^n:([^:]+):o$/.exec(token);
return match ? nodeSourceValue(match[1]) : [];
}
function netSourceValue(token) {
const root = find(token);
if (netDetectorSources.has(root)) return netDetectorSources.get(root);
if (visitingSourceNets.has(root)) return [];
visitingSourceNets.add(root);
let sources = [];
for (const member of members.get(root) || [token]) {
if ((member.startsWith("i:") || /^n:[^:]+:o$/.test(member)) && driverValue(member)) {
sources = unionSources(sources, driverDetectorSources(member));
}
}
visitingSourceNets.delete(root);
netDetectorSources.set(root, sources);
return sources;
}
function nodeSourceValue(id) {
if (Object.prototype.hasOwnProperty.call(nodeDetectorSources, id)) return nodeDetectorSources[id];
if (visitingSourceNodes.has(id) || !nodeValue(id)) return [];
const node = nodeMap.get(id);
if (!node || unstableNodes.has(id) || node.type === "on" || node.type === "not") {
nodeDetectorSources[id] = [];
return [];
}
visitingSourceNodes.add(id);
let sources = netSourceValue(`n:${id}:a`);
if (node.type === "and") sources = unionSources(sources, netSourceValue(`n:${id}:b`));
visitingSourceNodes.delete(id);
nodeDetectorSources[id] = sources;
return sources;
}
const outputs = Object.create(null);
for (const port of config.outputs) outputs[port.id] = netValue(`o:${port.id}`);
const outputDetectorSources = Object.create(null);
for (const port of config.outputs) {
outputs[port.id] = netValue(`o:${port.id}`);
outputDetectorSources[port.id] = outputs[port.id] ? netSourceValue(`o:${port.id}`) : [];
}
if (board) {
board._circuitInputState = inputs;
board._circuitInputDetectorSources = inputDetectorSources;
board._circuitNodeState = nodeValues;
board._circuitOutputState = outputs;
board._circuitOutputDetectorSources = outputDetectorSources;
for (const port of config.outputs) {
const term = terminal(board, `o:${port.id}`);
if (term) term.signalActive = Boolean(outputs[port.id]);
if (term) {
term.signalActive = Boolean(outputs[port.id]);
term._signalDetectorSources = [...(outputDetectorSources[port.id] || [])];
}
}
}
return { inputs, nodes: nodeValues, outputs };
return { inputs, nodes: nodeValues, outputs, inputDetectorSources, outputDetectorSources };
}
function nearestPin(config, x, y, maxDistance = 17) {
@ -698,7 +764,11 @@
dialog = document.createElement("div");
dialog.id = "circuitBoardEditor";
dialog.className = "rotator-editor circuit-grid-editor hidden";
dialog.innerHTML = `<div class="rotator-editor-panel circuit-grid-panel" role="dialog" aria-modal="true" aria-labelledby="circuitBoardTitle">
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "circuitBoardTitle");
dialog.setAttribute("aria-hidden", "true");
dialog.innerHTML = `<div class="rotator-editor-panel circuit-grid-panel">
<div class="rotator-editor-titlebar"><h2 id="circuitBoardTitle">\u57fa\u76e4</h2><button class="rotator-editor-close" type="button" data-action="close" aria-label="\u9589\u3058\u308b">\u00d7</button></div>
<div class="circuit-port-count-controls">
<label>\u6a2a\u5e45 <button type="button" data-grid-delta="cols:-1">\u2212</button><input id="circuitGridCols" type="number" min="${MIN_COLS}" max="${MAX_COLS}" step="1"><button type="button" data-grid-delta="cols:1">\uff0b</button></label>
@ -1089,12 +1159,16 @@
dialog.querySelector("[data-action=clear]").onclick = () => { snapshot(); draft.nodes = []; draft.wires = []; draw(); };
dialog.querySelector("[data-action=reset]").onclick = () => { snapshot(); draft = defaultConfig(); syncControls(); resizeCanvas(); draw(); };
const close = () => {
dialog.classList.add("hidden");
if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dialog);
else { dialog.classList.add("hidden"); dialog.setAttribute("aria-hidden", "true"); }
document.removeEventListener("keydown", handleEditorKeydown, true);
canvas.onpointerdown = null; canvas.onpointermove = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onpointerleave = null; canvas.oncontextmenu = null;
};
dialog.querySelector("[data-action=close]").onclick = close;
dialog.querySelector("[data-action=cancel]").onclick = close;
if (dialog._tarinaiCloseRequestHandler) dialog.removeEventListener("tarinai:dialog-close-request", dialog._tarinaiCloseRequestHandler);
dialog._tarinaiCloseRequestHandler = close;
dialog.addEventListener("tarinai:dialog-close-request", close);
dialog.querySelector("[data-action=save]").onclick = () => {
applySerializedConfig(board, draft); evaluate(board); remapAttachedWireEndpoints(board, worldRef);
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("circuit-board-edited"); }
@ -1102,7 +1176,10 @@
global.render?.(); close();
};
dialog.onclick = event => { if (event.target === dialog) close(); };
syncControls(); resizeCanvas(); setTool("wire"); dialog.classList.remove("hidden"); draw();
syncControls(); resizeCanvas(); setTool("wire");
if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dialog, document.activeElement);
else { dialog.classList.remove("hidden"); dialog.setAttribute("aria-hidden", "false"); }
draw();
return true;
}

View file

@ -10,12 +10,12 @@
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
const MOOD_IDS = Object.freeze(["relaxed", "devastation", "crisis", "confusion", "fearful", "disease_spread", "weakened", "breeding", "happy", "overcrowded", "isolated"]);
const STATE_IDS = Object.freeze(["idle", "sleep"]);
const STATE_IDS = Object.freeze(["idle", "sleep", "eat", "fight", "panic", "sunbath", "birth_ritual", "build", "social", "flee", "wander", "defeated"]);
const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]);
const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]);
const LIFE_MODE_IDS = Object.freeze(["", "mercury"]);
const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]);
const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]);
const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry", "decomposing", "fertile_soil"]);
function indexMap(list) {
return Object.freeze(Object.fromEntries(list.map((id, index) => [id, index])));

View file

@ -29,10 +29,7 @@
if (event.target === dialog || event.target.closest("[data-game-dialog-cancel]")) settle(false);
else if (event.target.closest("[data-game-dialog-confirm]")) settle(true);
});
document.addEventListener("keydown", event => {
if (!active || dialog.classList.contains("hidden")) return;
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation?.(); settle(false); }
}, true);
dialog.addEventListener("tarinai:dialog-close-request", () => { if (active) settle(false); });
return dialog;
}
@ -52,7 +49,8 @@
confirmButton.classList.toggle("primary", !active.options.danger);
}
if (cancelButton) cancelButton.textContent = active.options.cancelText || "\u30ad\u30e3\u30f3\u30bb\u30eb";
dialog.classList.remove("hidden");
if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dialog, active.restoreFocus);
else { dialog.classList.remove("hidden"); dialog.setAttribute("aria-hidden", "false"); }
global.setTimeout(() => confirmButton?.focus?.(), 0);
}
@ -60,8 +58,9 @@
if (!active) return;
const current = active;
active = null;
document.getElementById("gameConfirmDialog")?.classList.add("hidden");
current.restoreFocus?.focus?.();
const dialog = document.getElementById("gameConfirmDialog");
if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dialog);
else { dialog?.classList.add("hidden"); dialog?.setAttribute("aria-hidden", "true"); current.restoreFocus?.focus?.(); }
current.resolve(Boolean(accepted));
global.setTimeout(pump, 0);
}

View file

@ -322,7 +322,7 @@
world?.markItemBucketsDirty?.("poison-gas-can-burst");
world?.markSpatialDirty?.("poison-gas-can-burst");
world && (world.drawListDirty=true);
global.audio?.explode?.();
global.TarinaiAudio?.explode?.();
world?.log?.("\u6bd2\u30ac\u30b9\u7f36\u304c\u7834\u88c2\u3057\u3001\u6bd2\u30ac\u30b9\u304c\u5e83\u304c\u3063\u305f\u3002","accident");
return true;
}

View file

@ -6,7 +6,7 @@
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
item.age += dt;
item.age = Math.max(0, Number.isFinite(Number(item.age)) ? Number(item.age) : 0) + Math.max(0, Number(dt) || 0);
if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt);
if (item.type === "toilet") item.toiletFlash = Math.max(0, (item.toiletFlash || 0) - dt * 1.8);
if (item.dropTimer > 0) {

View file

@ -680,7 +680,7 @@
item.y = num(item.y) + dy;
if (item.physicsBody?.pose) { item.physicsBody.pose.x = item.x; item.physicsBody.pose.y = item.y; }
}
const pad = Number(global.CONFIG?.worldPadding || 16) || 16;
const pad = Number((typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 16) || 16;
const rr = Math.max(18, Math.min(120, reach(item) * 0.24));
if (worldRef) {
item.x = clamp(item.x, pad + rr, num(worldRef.w, 1000) - pad - rr);
@ -1362,7 +1362,7 @@
function constrainPassiveBlockInsideWorld(item, worldRef) {
if (!item || !worldRef) return false;
const pad = Number(global.CONFIG?.worldPadding || 16) || 16;
const pad = Number((typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 16) || 16;
const reachValue = Math.max(24, reach(item));
let changed = false;
if (num(item.x) < pad + reachValue * 0.25) { item.x = pad + reachValue * 0.25; pset(item, "xv", Math.abs(ps(item, "xv", 0)) * 0.38, "world-boundary"); changed = true; }

View file

@ -50,12 +50,12 @@
}
function rectOutside(worldRef, rect, itemType = "") {
if (!worldRef || !rect) return true;
const pad = global.CONFIG?.worldPadding || 30;
const pad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 30;
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < 0 || num(rect.bottom) > (worldRef.h || 0);
}
function circleOutside(worldRef, x, y, radius) {
if (!worldRef) return true;
const pad = global.CONFIG?.worldPadding || 30;
const pad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 30;
const r = Math.max(0, num(radius));
return num(x) - r < pad || num(y) - r < 0 || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0);
}
@ -152,7 +152,7 @@
}
function clampPointFor(worldRef, item, x = item?.x || 0, y = item?.y || 0) {
if (!worldRef || !item) return { x, y };
const pad = global.CONFIG?.worldPadding || 30;
const pad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 30;
const rects = rectsFor(worldRef, item);
if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0;
@ -222,7 +222,7 @@
const r = tmp.r;
let extra = {};
if (type === "dry_ice" || type === "stove") {
const range = Math.max(24, global.CONFIG?.temperatureItemRange ?? 190);
const range = Math.max(24, (typeof CONFIG !== "undefined" ? CONFIG.temperatureItemRange : global.CONFIG?.temperatureItemRange) ?? 190);
extra.influence = { kind: "temperatureEffectRange", shape: "circle", x, y, radius: range, type };
} else if (type === "leakage_device") {
const range = global.TarinaiItemEnvironmentHazardSystem?.leakageRadius?.(tmp) ?? Math.max(260, (tmp.r || 34) * 8.2);

View file

@ -24,6 +24,7 @@
"normalItems",
"physicsItems",
"achievementMetadata",
"extendedState",
]);
let lastEncodeStats = null;
let lastDecodeStats = null;
@ -477,12 +478,6 @@
return readBitFields(reader, Array.from({ length: count }, () => bits)).map(v => v * Math.max(1, step));
}
function writeFixedPacked(writer, arr = [], count = 0, bits = 8, step = 1) {
writeQuantizedBitFields(writer, arr, count, bits, step);
}
function readFixedPacked(reader, count = 0, bits = 8, step = 1) {
return readQuantizedBitFields(reader, count, bits, step);
}
function writeFixedDiffPacked(writer, base = [], current = [], count = 0, bits = 8) {
let mask = 0;
const values = [];
@ -526,14 +521,14 @@
return false;
}
function writeTimers(writer, timers = []) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
let mask = 0;
for (let i = 0; i < defaults.length; i++) if (valuesDiffer(timers?.[i], defaults[i])) mask |= (1 << i);
writeBitFields(writer, [mask], [defaults.length]);
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) writer.s(timers[i] || 0);
}
function readTimers(reader) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
const out = defaults.slice();
const [mask] = readBitFields(reader, [defaults.length]);
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) out[i] = reader.s();
@ -696,19 +691,6 @@
Math.round((Number(genetics.temperatureOffset) || 0) * 10),
];
}
function writeSignedDiffList(writer, base = [], current = [], count = 0) {
let mask = 0;
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
writeBitFields(writer, [mask], [count]);
for (let i = 0; i < count; i++) if (mask & (1 << i)) writer.s((current?.[i] || 0) - (base?.[i] || 0));
}
function readSignedDiffList(reader, base = [], count = 0) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = (base?.[i] || 0) + reader.s();
return out;
}
class CompactBitWriter {
constructor() { this.bytes = []; this.buffer = 0; this.bits = 0; }
write(value = 0, width = 0) {
@ -765,7 +747,7 @@
const flags = row[0] || 0;
const birth = birthSeedFromDescriptor(row[1], worldSeed, rowIndex + 1);
const defaultPersonality = seedPersonalityArray(birth.seed, flags);
const defaultTimers = [0, 0, 0, 0, 0, 0, 0, 0, 100];
const defaultTimers = [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
const genetics = Array.isArray(row[18]) ? row[18] : [];
const fightStats = Array.isArray(row[19]) ? row[19] : [];
let bodyMask = 0;
@ -776,7 +758,7 @@
if (arrayHasValues(row[14], [0, 0, 0])) bodyMask |= 16;
const pinIdx = row[15] ?? -1;
const nestIdx = row[16] ?? -1;
const stateId = row[17]?.[0] ? 1 : 0;
const stateId = Math.max(0, Math.min(15, Number(row[17]?.[0]) || 0));
const stateTarget = row[17]?.[1] ?? -1;
const geneticsBase = seedGeneticsArray(birth.seed, flags);
let linkMask = 0;
@ -786,7 +768,7 @@
flags, birth, bodyMask, linkMask, pinIdx, nestIdx, stateId, stateTarget, genetics, fightStats,
geneticsBase, geneticsMask: diffMask(geneticsBase, genetics, 5), fightMask: diffMask([0, 0, 0], fightStats, 3),
hunger: Math.max(0, Math.min(100, Math.round(Number(row[6]) || 0))),
energy: Math.max(0, Math.min(100, Math.round(Number(row[7]) || 0))),
energy: Math.max(0, Math.min(240, Math.round(Number(row[7]) || 0))),
sleepPressure: Math.max(0, Math.min(100, Math.round(Number(row[8]) || 0))),
};
}
@ -794,11 +776,11 @@
const TARINAI_COLUMN_LAYOUT = Object.freeze([
["flags", 10],
["hunger", 7],
["energy", 7],
["energy", 8],
["sleepPressure", 7],
["bodyMask", 5],
["linkMask", 2],
["stateId", 1],
["stateId", 4],
["geneticsMask", 5],
["fightMask", 3],
]);
@ -899,7 +881,7 @@
const current = (bodyMask & 1) ? readFixedDiffPacked(reader, defaultPersonality, 4, 8) : defaultPersonality;
const parents = (bodyMask & 2) ? readIndexList(reader) : [];
const relationships = (bodyMask & 4) ? readRelationships(reader) : [];
const timers = (bodyMask & 8) ? readTimers(reader) : [0, 0, 0, 0, 0, 0, 0, 0, 100];
const timers = (bodyMask & 8) ? readTimers(reader) : [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
const modes = (bodyMask & 16) ? readModes(reader) : [0, 0, 0];
const pinIdx = (linkMask & 1) ? reader.u() : -1;
const nestIdx = (linkMask & 2) ? reader.u() : -1;
@ -1473,13 +1455,16 @@
return items;
}
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
const ACHIEVEMENT_SPELL_BINARY_VERSION = 8;
const ACHIEVEMENT_METADATA_BINARY_VERSION = 3;
const ACHIEVEMENT_SPELL_BINARY_VERSION = 11;
const ACHIEVEMENT_MASK_BYTES = 10;
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17, 5, 14, 7]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071, 30, 10000, 100]);
const ACHIEVEMENT_PROGRESS_BITS_V9 = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17, 5, 14, 7]);
const ACHIEVEMENT_PROGRESS_MAX_V9 = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071, 30, 10000, 100]);
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([...ACHIEVEMENT_PROGRESS_BITS_V9, 4, 6, 8]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([...ACHIEVEMENT_PROGRESS_MAX_V9, 10, 33, 200]);
function finiteNumberOrNull(value) {
if (value === null || value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
@ -1501,9 +1486,9 @@
}
function achievementMaskBytesFromSpell(spell = null) {
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [], mysteryDrugIds: [] };
const version = Number(spell[0]);
if (version !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version");
if (![9, 10, ACHIEVEMENT_SPELL_BINARY_VERSION].includes(version)) throw new Error("unsupported achievement spell version");
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0)));
const extra = Math.max(0, Math.min(0xffff, Math.floor(Number(spell[3]) || 0)));
@ -1514,21 +1499,26 @@
const baseMinute = toSafeUInt(spell[4]);
const deltas = Array.isArray(spell[5]) ? spell[5].map(toSafeUInt) : [];
const progress = Array.isArray(spell[6]) ? spell[6] : [];
const mysteryDrugIds = version >= 10 && Array.isArray(spell[7])
? [...new Set(spell[7].map(value => String(value || "")).filter(Boolean))].slice(0, 10)
: [];
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
while (value) { unlockedCount += value & 1; value >>>= 1; }
}
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
return { bytes, baseMinute, deltas, progress };
return { bytes, baseMinute, deltas, progress, mysteryDrugIds };
}
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = [], version = ACHIEVEMENT_SPELL_BINARY_VERSION, mysteryDrugIds = []) {
let low = 0;
let high = 0;
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
return [ACHIEVEMENT_SPELL_BINARY_VERSION, low >>> 0, high >>> 0, (Number(bytes[8]) || 0) + (Number(bytes[9]) || 0) * 256, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
const spell = [version, low >>> 0, high >>> 0, (Number(bytes[8]) || 0) + (Number(bytes[9]) || 0) * 256, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
if (version >= 10) spell.push([...new Set((mysteryDrugIds || []).map(value => String(value || "")).filter(Boolean))].slice(0, 10));
return spell;
}
function writeAchievementProgress(writer, values = [], bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
const normalized = maxima.map((max, index) => Math.max(0, Math.min(max, toSafeUInt(values?.[index]))));
@ -1560,9 +1550,11 @@
writer.raw(normalized.bytes);
writer.u(normalized.baseMinute);
for (const delta of normalized.deltas) writer.u(delta);
writeAchievementProgress(writer, normalized.progress);
writeAchievementProgress(writer, normalized.progress, ACHIEVEMENT_PROGRESS_BITS, ACHIEVEMENT_PROGRESS_MAX);
writer.u(normalized.mysteryDrugIds.length);
for (const id of normalized.mysteryDrugIds) writer.str(id);
}
function readAchievementSpell(reader) {
function readAchievementSpell(reader, metadataVersion = ACHIEVEMENT_METADATA_BINARY_VERSION) {
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
let unlockedCount = 0;
for (const byte of bytes) {
@ -1572,8 +1564,21 @@
const baseMinute = reader.u();
const deltas = [];
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
const progress = readAchievementProgress(reader, ACHIEVEMENT_PROGRESS_BITS, ACHIEVEMENT_PROGRESS_MAX);
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
const current = metadataVersion >= 3;
const progress = readAchievementProgress(
reader,
current ? ACHIEVEMENT_PROGRESS_BITS : ACHIEVEMENT_PROGRESS_BITS_V9,
current ? ACHIEVEMENT_PROGRESS_MAX : ACHIEVEMENT_PROGRESS_MAX_V9,
);
const mysteryDrugIds = [];
if (current) {
const count = Math.min(10, reader.u());
for (let i = 0; i < count; i++) {
const id = String(reader.str() || "");
if (id && !mysteryDrugIds.includes(id)) mysteryDrugIds.push(id);
}
}
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress, current ? ACHIEVEMENT_SPELL_BINARY_VERSION : 9, mysteryDrugIds);
}
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
const value = metadata && typeof metadata === "object" ? metadata : {};
@ -1588,19 +1593,27 @@
Math.min(10, toSafeUInt(w[4])),
], [6, 8, 4]);
const timeDefaults = [null, -1, null, 0, -1, -1];
const timeValues = [w[5], w[6], w[7], w[8], w[9], w[10]];
let timeMask = 0;
const timeDefaults = [null, -1, null, 0, -1, -1, -1];
const timeValues = [w[5], w[6], w[7], w[8], w[9], w[10], w[11]];
// Two-bit state per value: 0 = schema default, 1 = explicit null,
// 2 = explicit finite number. This preserves null separately from 0/-1.
const timeStates = [];
const encodedTimes = [];
timeValues.forEach((raw, index) => {
const finite = finiteNumberOrNull(raw);
const defaultValue = timeDefaults[index];
const sameAsDefault = finite == null ? defaultValue == null : finite === defaultValue;
if (sameAsDefault) return;
timeMask |= 1 << index;
encodedTimes.push(quantizedTenths(finite == null ? defaultValue || 0 : finite));
if (finite == null) {
timeStates.push(defaultValue == null ? 0 : 1);
return;
}
if (finite === defaultValue) {
timeStates.push(0);
return;
}
timeStates.push(2);
encodedTimes.push(quantizedTenths(finite));
});
writeBitFields(writer, [timeMask], [timeDefaults.length]);
writeBitFields(writer, timeStates, timeStates.map(() => 2));
encodedTimes.forEach(value => writer.s(value));
const tarRows = Array.isArray(value.t) ? value.t : [];
@ -1611,12 +1624,21 @@
const treatment = toSafeUInt(row[1]);
const fightAt = finiteNumberOrNull(row[2]);
const saunaAt = finiteNumberOrNull(row[3]);
if (feed || treatment || fightAt != null || saunaAt != null) activeTarRows.push({ index, feed, treatment, fightAt, saunaAt });
const slaveDepth = toSafeUInt(row[4]);
const kingDepth = toSafeUInt(row[5]);
if (feed || treatment || fightAt != null || saunaAt != null || slaveDepth || kingDepth) {
activeTarRows.push({ index, feed, treatment, fightAt, saunaAt, slaveDepth, kingDepth });
}
}
writer.u(activeTarRows.length);
let previousTarIndex = -1;
for (const row of activeTarRows) { writer.u(row.index - previousTarIndex - 1); previousTarIndex = row.index; }
for (const row of activeTarRows) { writer.u(row.feed); writer.u(row.treatment); }
for (const row of activeTarRows) {
writer.u(row.feed);
writer.u(row.treatment);
writer.u(row.slaveDepth);
writer.u(row.kingDepth);
}
const tarFlags = [];
for (const row of activeTarRows) tarFlags.push(row.fightAt != null ? 1 : 0, row.saunaAt != null ? 1 : 0);
if (tarFlags.length) writeBitFields(writer, tarFlags, tarFlags.map(() => 1));
@ -1645,18 +1667,22 @@
}
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0) {
const version = reader.b();
if (version !== ACHIEVEMENT_METADATA_BINARY_VERSION) throw new Error("unsupported achievement metadata schema");
if (![2, ACHIEVEMENT_METADATA_BINARY_VERSION].includes(version)) throw new Error("unsupported achievement metadata schema");
const [flags] = readBitFields(reader, [1]);
const w = [readPositiveDeltaList(reader), readPositiveDeltaList(reader)];
const [directFeed, robotClean, enemyAntKills] = readBitFields(reader, [6, 8, 4]);
w.push(directFeed, robotClean, enemyAntKills);
const timeDefaults = [null, -1, null, 0, -1, -1];
const [timeMask] = readBitFields(reader, [timeDefaults.length]);
const timeDefaults = version >= 3 ? [null, -1, null, 0, -1, -1, -1] : [null, -1, null, 0, -1, -1];
const timeStates = readBitFields(reader, timeDefaults.map(() => 2));
for (let index = 0; index < timeDefaults.length; index++) {
w.push(timeMask & (1 << index) ? unquantizedTenths(reader.s()) : timeDefaults[index]);
const state = timeStates[index];
if (state === 0) w.push(timeDefaults[index]);
else if (state === 1) w.push(null);
else if (state === 2) w.push(unquantizedTenths(reader.s()));
else throw new Error("invalid achievement metadata time state");
}
const t = Array.from({ length: tarinaiCount }, () => [0, 0, null, null]);
const t = Array.from({ length: tarinaiCount }, () => [0, 0, null, null, 0, 0]);
const tarCount = reader.u();
const tarIndexes = [];
let previousTarIndex = -1;
@ -1665,13 +1691,22 @@
if (previousTarIndex >= tarinaiCount) throw new Error("invalid achievement tarinai metadata index");
tarIndexes.push(previousTarIndex);
}
const tarCounters = tarIndexes.map(() => [reader.u(), reader.u()]);
const tarCounters = tarIndexes.map(() => version >= 3
? [reader.u(), reader.u(), reader.u(), reader.u()]
: [reader.u(), reader.u(), 0, 0]);
const tarFlagWidths = Array.from({ length: tarCount * 2 }, () => 1);
const tarFlags = tarFlagWidths.length ? readBitFields(reader, tarFlagWidths) : [];
tarIndexes.forEach((index, rowIndex) => {
const fightAt = tarFlags[rowIndex * 2] ? unquantizedTenths(reader.s()) : null;
const saunaAt = tarFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
t[index] = [tarCounters[rowIndex][0], tarCounters[rowIndex][1], fightAt, saunaAt];
t[index] = [
tarCounters[rowIndex][0],
tarCounters[rowIndex][1],
fightAt,
saunaAt,
tarCounters[rowIndex][2],
tarCounters[rowIndex][3],
];
});
const i = Array.from({ length: itemCount }, () => [0, null]);
@ -1691,7 +1726,7 @@
i[index] = [placed, placedAt];
});
const metadata = { w, t, i };
if (flags & 1) metadata.s = readAchievementSpell(reader);
if (flags & 1) metadata.s = readAchievementSpell(reader, version);
return metadata;
}
@ -1730,6 +1765,7 @@
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
writeItemBlocks(writer, itemRows);
writer.category("achievementMetadata", () => writeAchievementMetadata(writer, snapshot.g || null, tarinaiRows.length, itemRows.length));
writer.category("extendedState", () => writer.str(JSON.stringify(snapshot.x || {})));
return writer.out();
} finally {
activeStringWriteMap = null;
@ -1743,7 +1779,7 @@
const formatHeader = reader.b();
const schema = formatHeader & 0x7f;
const hasStringTable = Boolean(formatHeader & 0x80);
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
if (![54, BINARY_SCHEMA_VERSION].includes(schema)) throw new Error("unsupported binary save schema");
readStringTable(reader, hasStringTable);
const w = readWorld(reader);
const tCount = reader.u();
@ -1758,8 +1794,10 @@
} catch (_) {
throw new Error("invalid achievement metadata in binary save");
}
let x = {};
try { x = JSON.parse(reader.str() || "{}"); } catch (_) { throw new Error("invalid extended state in binary save"); }
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items, g };
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items, r: [], g, x };
} finally {
activeStringReadList = null;
}

View file

@ -8,8 +8,8 @@
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
const SNAPSHOT_VERSION = 41;
const BINARY_SCHEMA_VERSION = 53;
const SNAPSHOT_VERSION = 42;
const BINARY_SCHEMA_VERSION = 55;
function moodValue(index, fallback = "relaxed") {
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));

View file

@ -65,10 +65,13 @@
dialog.className = "field-dialog hidden";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "saveDialogTitle");
dialog.setAttribute("aria-hidden", "true");
dialog.tabIndex = -1;
dialog.innerHTML = `
<div class="field-dialog-panel save-dialog-panel">
<h2 id="saveDialogTitle">\u30bb\u30fc\u30d6 / \u30ed\u30fc\u30c9</h2>
<p class="hint save-warning">\u30bb\u30fc\u30d6\u306f\u5b9f\u9a13\u7684\u6a5f\u80fd\u3067\u3042\u308a\u4fdd\u8a3c\u5bfe\u8c61\u5916\u3067\u3059\u3002</p>
<p class="hint save-warning">\u500b\u4f53\u30fb\u7269\u4f53\u30fb\u5929\u5019\u30fb\u30ed\u30b0\u306e\u72b6\u614b\u3092\u4fdd\u5b58\u3057\u307e\u3059\u3002\u884c\u52d5\u30af\u30fc\u30eb\u30c0\u30a6\u30f3\u306e\u307f\u30bb\u30c3\u30b7\u30e7\u30f3\u5185\u72b6\u614b\u306e\u305f\u3081\u5fa9\u5143\u3055\u308c\u307e\u305b\u3093\u3002</p>
<div id="saveSlotList" class="save-slot-list"></div>
<div class="save-export-panel">
<div class="save-export-actions">
@ -109,11 +112,22 @@
function openDialog() {
const dialog = ensureDialog();
renderSlotList();
dialog.classList.remove("hidden");
if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dialog);
else {
dialog.classList.remove("hidden");
dialog.setAttribute("aria-hidden", "false");
global.setTimeout(() => dialog.querySelector("button:not([disabled]), textarea:not([disabled])")?.focus?.(), 0);
}
}
function closeDialog() {
document.getElementById("saveDialog")?.classList.add("hidden");
const dialog = document.getElementById("saveDialog");
if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dialog);
else {
dialog?.classList.add("hidden");
dialog?.setAttribute("aria-hidden", "true");
document.getElementById("saveBtn")?.focus?.();
}
}
function bindSaveSystem() {
@ -121,12 +135,13 @@
if (!btn || btn.dataset.saveBound === "1") return;
btn.dataset.saveBound = "1";
btn.addEventListener("click", () => {
global.audio?.uiClick?.();
global.TarinaiAudio?.uiClick?.();
openDialog();
});
const dialog = ensureDialog();
if (dialog.dataset.saveDialogBound === "1") return;
dialog.dataset.saveDialogBound = "1";
dialog.addEventListener("tarinai:dialog-close-request", closeDialog);
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
const saveBtn = e.target.closest("[data-save-slot]");
@ -137,7 +152,7 @@
try {
await saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.TarinaiAudio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
@ -155,7 +170,7 @@
try {
await loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.TarinaiAudio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
@ -173,7 +188,7 @@
})) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.TarinaiAudio?.delete?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u6d88\u3057\u307e\u3057\u305f\u3002`);
return;
}

View file

@ -185,13 +185,22 @@
}
}
function setEffectivePower(node, directDriven, directOn, worldRef) {
function setEffectivePower(node, directDriven, directOn, worldRef, provenance = {}) {
const wasOn = Boolean(node?._achievementSignalWasOn);
node._directSignalDriven = Boolean(directDriven);
node._directSignalOn = Boolean(directDriven && directOn);
node._achievementSignalWasOn = node._directSignalOn;
if (!wasOn && node._directSignalOn) {
global.TarinaiAchievements?.recordSignalActivation?.({ world: worldRef, target: node, type: node.type });
const detectorSources = [...new Set((Array.isArray(provenance.detectorSources) ? provenance.detectorSources : []).filter(Boolean))];
global.TarinaiAchievements?.recordSignalActivation?.({
world: worldRef,
target: node,
type: node.type,
source: detectorSources[0] || null,
detectorSources,
detectorDriven: detectorSources.length > 0,
connected: Boolean(directDriven),
});
if (node.type === "robot_cleaner") global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot: node, source: "signal-power" });
}
node._signalDriven = node._directSignalDriven;
@ -323,7 +332,12 @@
visited.add(node); nodes.push(node);
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
}
const component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) };
const component = {
nodes,
active: false,
hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")),
detectorSources: [],
};
components.push(component);
nodes.forEach(node => componentOf.set(node, component));
}
@ -331,33 +345,55 @@
// Board outputs can depend on other boards, so resolve the graph to a
// fixed point. Unstable internal combinational cycles are forced OFF by the
// board evaluator; external feedback is still bounded to avoid hanging the simulation.
const detectorSourceSignature = sources => [...new Set((sources || []).map(source => String(source?.id || source?.familyKey || ""))).values()].sort().join("|");
const refreshComponentState = component => {
const directDetectors = component.nodes.filter(node => SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive);
const outputDetectors = component.nodes.flatMap(node => (
node.type === "circuit_terminal" && node.direction === "output" && node.signalActive
? (Array.isArray(node._signalDetectorSources) ? node._signalDetectorSources : [])
: []
));
const nextSources = [...new Set([...directDetectors, ...outputDetectors].filter(Boolean))];
const active = component.hasSource && component.nodes.some(node => (
(SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive)
|| (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive)
));
const changed = active !== component.active
|| detectorSourceSignature(nextSources) !== detectorSourceSignature(component.detectorSources);
component.active = active;
component.detectorSources = active ? nextSources : [];
return changed;
};
const maxCircuitPasses = Math.min(256, Math.max(16, boards.length * 2 + 4));
for (let pass = 0; pass < maxCircuitPasses; pass += 1) {
let changed = false;
for (const component of components) {
const active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
if (active !== component.active) { component.active = active; changed = true; }
}
for (const component of components) changed = refreshComponentState(component) || changed;
for (const board of boards) {
const inputs = Object.create(null);
const inputProvenance = Object.create(null);
for (const port of circuit?.ports?.(board) || []) {
if (port.direction !== "input") continue;
const terminal = circuit.terminal(board, port.portId);
inputs[port.id] = Boolean(componentOf.get(terminal)?.active);
const component = componentOf.get(terminal);
inputs[port.id] = Boolean(component?.active);
inputProvenance[port.id] = component?.detectorSources || [];
}
const before = JSON.stringify(board._circuitOutputState || {});
circuit?.evaluate?.(board, inputs);
if (before !== JSON.stringify(board._circuitOutputState || {})) changed = true;
const beforeSources = JSON.stringify(Object.fromEntries(Object.entries(board._circuitOutputDetectorSources || {}).map(([id, sources]) => [id, detectorSourceSignature(sources)])));
circuit?.evaluate?.(board, inputs, inputProvenance);
const afterSources = JSON.stringify(Object.fromEntries(Object.entries(board._circuitOutputDetectorSources || {}).map(([id, sources]) => [id, detectorSourceSignature(sources)])));
if (before !== JSON.stringify(board._circuitOutputState || {}) || beforeSources !== afterSources) changed = true;
}
if (!changed) break;
}
for (const component of components) {
component.active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
refreshComponentState(component);
for (const node of component.nodes) {
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
node._wireSignalDriven = component.hasSource;
node._wireSignalOn = component.hasSource && component.active;
setEffectivePower(node, component.hasSource, component.active, worldRef);
setEffectivePower(node, component.hasSource, component.active, worldRef, { detectorSources: component.detectorSources });
}
}
for (const entry of wires) {

View file

@ -79,7 +79,7 @@
function cloneSnapshotPlain(value, depth = 0) {
if (value == null) return value;
const type = typeof value;
if (type === "number") return Number.isFinite(value) ? value : 0;
if (type === "number") return Number.isFinite(value) ? value : null;
if (type === "string" || type === "boolean") return value;
if (type === "function" || type === "symbol" || type === "undefined") return undefined;
if (depth > 8) return undefined;
@ -100,6 +100,158 @@
}
return out;
}
function cloneEntityState(entity, skip = []) {
const blocked = new Set(["world", "id", ...skip]);
const out = {};
for (const [key, value] of Object.entries(entity || {})) {
if (blocked.has(key) || DEFAULT_SKIP.has(key)) continue;
const cloned = cloneSnapshotPlain(value);
if (cloned !== undefined) out[key] = cloned;
}
return out;
}
function compactSnapshotExtension(worldRef, tarinaiLive, itemRecords) {
const tarIds = tarinaiLive.map(t => String(t?.id || ""));
const itemIds = itemRecords.map(rec => String(rec?.item?.id || ""));
const tarStates = tarinaiLive.map(t => {
// SV-11 is intentionally not serialized: actionCooldowns remain session-local.
const state = cloneEntityState(t, [
"liveToken", "familyKey", "birthSeed", "birthSerial", "parents", "parentNames", "children",
"actionCooldowns", "relationships", "target", "panicTarget", "targetRef", "targetEntity", "source",
]);
state.__targetId = String(t?.target?.id || t?.targetId || "");
state.__panicTargetId = String(t?.panicTarget?.id || "");
state.__relationships = cloneSnapshotPlain(t?.relationships || {}) || {};
return state;
});
const itemStates = itemRecords.map(rec => cloneEntityState(rec?.item, ["type", "targetRef", "source"]));
const antStates = (worldRef?.ants || []).filter(a => a && !a.dead).map(a => {
const state = cloneEntityState(a, ["targetRef", "source"]);
state.id = String(a.id || "");
return state;
});
return {
ids: { t: tarIds, i: itemIds },
w: {
nextWeatherChange: finiteOrNull(worldRef?.nextWeatherChange),
effects: cloneSnapshotPlain((worldRef?.effects || []).slice(-256)) || [],
logs: cloneSnapshotPlain((worldRef?.logs || []).slice(-512)) || [],
relationNotices: cloneSnapshotPlain(worldRef?.relationNotices || {}) || {},
resolvedFightIds: cloneSnapshotPlain(worldRef?.resolvedFightIds || {}) || {},
eventCounters: cloneSnapshotPlain(worldRef?.eventCounters || {}) || {},
},
t: tarStates,
i: itemStates,
a: antStates,
r: cloneSnapshotPlain((worldRef?.residues || []).filter(r => r && !r.dead && Number(r.amount || 0) > 0)) || [],
};
}
const ID_KEYED_SNAPSHOT_MAPS = new Set(["__relationships", "relationships"]);
function snapshotFieldContainsEntityId(key = "") {
const name = String(key || "");
return name === "__targetId" || name === "__panicTargetId" || /(?:Id|Ids)$/.test(name);
}
function remapSnapshotValue(value, idMap, depth = 0, fieldName = "") {
if (depth > 12 || value == null) return value;
if (typeof value === "string") {
return snapshotFieldContainsEntityId(fieldName) ? (idMap.get(value) || value) : value;
}
if (Array.isArray(value)) return value.map(v => remapSnapshotValue(v, idMap, depth + 1, fieldName));
if (!isPlainObject(value)) return value;
const out = {};
const remapKeys = ID_KEYED_SNAPSHOT_MAPS.has(String(fieldName || ""));
for (const [key, child] of Object.entries(value)) {
const mappedKey = remapKeys ? (idMap.get(key) || key) : key;
out[mappedKey] = remapSnapshotValue(child, idMap, depth + 1, key);
}
return out;
}
function applySnapshotExtension(extension, worldRef) {
if (!extension || typeof extension !== "object") return false;
const oldTarIds = Array.isArray(extension.ids?.t) ? extension.ids.t : [];
const oldItemIds = Array.isArray(extension.ids?.i) ? extension.ids.i : [];
const idMap = new Map();
oldTarIds.forEach((id, index) => { if (id && worldRef.tarinai?.[index]?.id) idMap.set(String(id), worldRef.tarinai[index].id); });
oldItemIds.forEach((id, index) => { if (id && worldRef.items?.[index]?.id) idMap.set(String(id), worldRef.items[index].id); });
const tarStates = Array.isArray(extension.t) ? extension.t : [];
for (let index = 0; index < worldRef.tarinai.length; index++) {
const t = worldRef.tarinai[index];
const raw = isPlainObject(tarStates[index]) ? tarStates[index] : null;
if (!t || !raw) continue;
const state = remapSnapshotValue(raw, idMap);
const targetId = String(state.__targetId || "");
const panicTargetId = String(state.__panicTargetId || "");
const relationships = isPlainObject(state.__relationships) ? state.__relationships : null;
delete state.__targetId;
delete state.__panicTargetId;
delete state.__relationships;
delete state.actionCooldowns;
Object.assign(t, state);
// Explicitly preserve the requested exception.
t.actionCooldowns = {};
if (relationships) t.relationships = relationships;
t.target = targetId ? (worldRef.tarinai.find(v => v?.id === targetId) || worldRef.items.find(v => v?.id === targetId) || null) : null;
t.panicTarget = panicTargetId ? (worldRef.tarinai.find(v => v?.id === panicTargetId) || worldRef.items.find(v => v?.id === panicTargetId) || null) : null;
if (t.sleepSession && typeof t.sleepSession === "object" && t.sleepSession.targetId) {
t.sleepSession.targetId = idMap.get(String(t.sleepSession.targetId)) || String(t.sleepSession.targetId);
}
if (typeof t.refreshEffectiveSize === "function") {
const savedEnergy = Number(t.energy);
t.refreshEffectiveSize();
if (Number.isFinite(savedEnergy)) t.energy = Math.max(0, Math.min(Number(t.maxEnergy) || 240, savedEnergy));
}
}
const itemStates = Array.isArray(extension.i) ? extension.i : [];
for (let index = 0; index < worldRef.items.length; index++) {
const item = worldRef.items[index];
const raw = isPlainObject(itemStates[index]) ? itemStates[index] : null;
if (!item || !raw) continue;
Object.assign(item, remapSnapshotValue(raw, idMap));
item.world = worldRef;
if (item.type === "grass") {
if (!Number.isFinite(Number(item.grassStage))) item.grassStage = global.grassStageFromGrowth?.(item.growth, item.amount);
global.TarinaiGrass?.normalize?.(item);
}
}
worldRef.residues = [];
worldRef.residueSerial = 0;
for (const row of (Array.isArray(extension.r) ? extension.r : [])) {
if (!row || typeof row !== "object") continue;
worldRef.addResidue?.(String(row.type || "trace"), Number(row.x) || 0, Number(row.y) || 0, remapSnapshotValue(row, idMap));
}
worldRef.rebuildResidueIndex?.();
worldRef.ants = [];
const antRows = Array.isArray(extension.a) ? extension.a : [];
const AntClass = global.AntActor || (typeof AntActor !== "undefined" ? AntActor : null);
if (AntClass) {
for (const row of antRows) {
if (!isPlainObject(row)) continue;
const state = remapSnapshotValue(row, idMap);
const ant = new AntClass(worldRef, state);
Object.assign(ant, state);
ant.world = worldRef;
ant.targetRef = ant.targetId ? (worldRef.tarinai.find(t => t?.id === ant.targetId) || null) : null;
worldRef.ants.push(ant);
}
}
const worldState = isPlainObject(extension.w) ? remapSnapshotValue(extension.w, idMap) : {};
if (Number.isFinite(Number(worldState.nextWeatherChange))) worldRef.nextWeatherChange = Number(worldState.nextWeatherChange);
worldRef.effects = Array.isArray(worldState.effects) ? worldState.effects : [];
worldRef.logs = Array.isArray(worldState.logs) ? worldState.logs : [];
worldRef.relationNotices = isPlainObject(worldState.relationNotices) ? worldState.relationNotices : {};
worldRef.resolvedFightIds = isPlainObject(worldState.resolvedFightIds) ? worldState.resolvedFightIds : {};
worldRef.eventCounters = isPlainObject(worldState.eventCounters) ? worldState.eventCounters : {};
return true;
}
function needsToArray(needs = {}) {
return NEED_KEYS.map(k => q(needs[k], 1));
}
@ -186,14 +338,14 @@
q(worldRef?.birthSerial, 1, 0),
q(worldRef?.tarinaiPopulationLimit, 1, 0),
q(worldRef?.objectLimit, 1, 0),
[...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
[...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
q(worldRef?.achievementPlayerPlacementCount, 1, 0),
[], // legacy achievement slot 12
[], // legacy achievement slot 13
0, // legacy achievement slot 14
q(worldRef?.achievementLastInterventionAt, 10, 0),
q(worldRef?.achievementNoDeathStartAt, 10, -10),
q(worldRef?.achievementDirectFeedCount, 1, 0),
0, // legacy achievement slot 17
elapsedDays,
q(worldRef?.achievementManualTarinaiAddedCount, 1, 0),
0, // legacy achievement slot 19
];
}
function applyCompactWorld(worldRef, arr = [], meta = []) {
@ -218,13 +370,8 @@
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
worldRef.tarinaiPopulationLimit = worldRef.normalizeColonyLimit?.(u(arr[10], 1, 0)) ?? Math.min(300, Math.max(0, u(arr[10], 1, 0)));
worldRef.objectLimit = worldRef.normalizeColonyLimit?.(u(arr[11], 1, 0)) ?? Math.min(300, Math.max(0, u(arr[11], 1, 0)));
worldRef.achievementNaturalSlaveGenerations = Array.isArray(arr[12]) ? [...new Set(arr[12].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
worldRef.achievementNaturalKingGenerations = Array.isArray(arr[13]) ? [...new Set(arr[13].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
worldRef.achievementPlayerPlacementCount = Math.max(0, u(arr[14], 1, 0));
worldRef.achievementLastInterventionAt = Math.max(0, u(arr[15], 10, totalTime));
worldRef.achievementNoDeathStartAt = u(arr[16], 10, -1);
worldRef.achievementDirectFeedCount = Math.max(0, u(arr[17], 1, 0));
worldRef.achievementManualTarinaiAddedCount = Math.min(100, Math.max(0, u(arr[19], 1, 0)));
worldRef.time = Math.max(0, totalTime);
worldRef.elapsedDays = encodedElapsedDays;
worldRef.day = worldRef.elapsedDays + 1;
@ -239,6 +386,7 @@
worldRef.setGroundType?.(enumValue(GROUND_IDS, arr[1], "soil"), { silent: true, force: true });
}
function finiteOrNull(value) {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
@ -246,13 +394,12 @@
const isUnlocked = id => Boolean(global.TarinaiAchievements?.isUnlocked?.(id));
const needSlaveGenerations = !isUnlocked("natural_zunchi_slave_5_generations");
const needKingGenerations = !isUnlocked("natural_tarinai_king_3_generations");
const needDirectFeed = !isUnlocked("direct_feed_33");
const needRobotClean = !isUnlocked("robot_cleaner_100");
const needEnemyFriend = !isUnlocked("enemy_enemy_friend");
const needSelfSufficient = !isUnlocked("self_sufficient");
const needIdleObserver = !isUnlocked("idle_observer_5_minutes");
const needSafeColony = !isUnlocked("safe_colony_25_5_minutes");
const needHappyColony = !isUnlocked("colony_happy");
const needEliteFew = !isUnlocked("elite_few");
const needOverprotective = !isUnlocked("overprotective");
const needSauna = !isUnlocked("sauna_cold_plunge");
const needQuickDelete = !isUnlocked("unplanned_city_30");
@ -260,23 +407,26 @@
const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;
const metadata = {
w: [
needSlaveGenerations ? [...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
needKingGenerations ? [...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
needDirectFeed ? Math.max(0, Math.floor(Number(worldRef?.achievementDirectFeedCount || 0) || 0)) : 0,
needRobotClean ? Math.max(0, Math.floor(Number(worldRef?.achievementRobotCleanCount || 0) || 0)) : 0,
[], // legacy lineage slot
[], // legacy lineage slot
0, // legacy direct-feed slot
0, // legacy robot-clean slot; old saves are still imported below
needEnemyFriend ? Math.max(0, Math.floor(Number(worldRef?.achievementEnemyAntKills || 0) || 0)) : 0,
needEnemyFriend ? finiteOrNull(worldRef?.achievementLastTarinaiDamageAt) : null,
null, // legacy last-damage slot
needSelfSufficient ? finiteOrNull(worldRef?.achievementSelfSufficientStartAt) : -1,
needSelfSufficient ? finiteOrNull(worldRef?.achievementLastDirectFeedAt) : null,
null, // legacy last-direct-feed slot
needIdleObserver ? finiteOrNull(worldRef?.achievementLastInterventionAt) : 0,
needSafeColony ? finiteOrNull(worldRef?.achievementNoDeathStartAt) : -1,
needHappyColony ? finiteOrNull(worldRef?.achievementHappyStartAt) : -1,
needEliteFew ? finiteOrNull(worldRef?.achievementEliteFewStartAt) : -1,
],
t: tarinaiLive.map(t => [
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectFeedCount || 0) || 0)) : 0,
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectTreatmentCount || 0) || 0)) : 0,
null,
needSauna ? finiteOrNull(t?._achievementSaunaHotAt) : null,
needSlaveGenerations ? Math.max(0, Math.floor(Number(t?._achievementNaturalSlaveLineageDepth || 0) || 0)) : 0,
needKingGenerations ? Math.max(0, Math.floor(Number(t?._achievementNaturalKingLineageDepth || 0) || 0)) : 0,
]),
i: itemRecords.map(rec => [
needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0,
@ -292,26 +442,24 @@
function applyAchievementMetadata(worldRef, metadata = null) {
if (!worldRef || !metadata || typeof metadata !== "object") return false;
const w = Array.isArray(metadata.w) ? metadata.w : [];
worldRef.achievementNaturalSlaveGenerations = Array.isArray(w[0]) ? [...new Set(w[0].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalSlaveGenerations || []);
worldRef.achievementNaturalKingGenerations = Array.isArray(w[1]) ? [...new Set(w[1].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalKingGenerations || []);
worldRef.achievementDirectFeedCount = Math.max(0, Math.floor(Number(w[2] || 0) || 0));
worldRef.achievementRobotCleanCount = Math.max(0, Math.floor(Number(w[3] || 0) || 0));
const legacyRobotCleanCount = Math.max(0, Math.floor(Number(w[3] || 0) || 0));
global.TarinaiAchievements?.importWorldAchievementProgress?.({ robotCleanCount: legacyRobotCleanCount, source: "snapshot-metadata" });
worldRef.achievementEnemyAntKills = Math.max(0, Math.floor(Number(w[4] || 0) || 0));
worldRef.achievementLastTarinaiDamageAt = w[5] != null && Number.isFinite(Number(w[5])) ? Number(w[5]) : -Infinity;
worldRef.achievementSelfSufficientStartAt = w[6] != null && Number.isFinite(Number(w[6])) ? Number(w[6]) : -1;
worldRef.achievementLastDirectFeedAt = w[7] != null && Number.isFinite(Number(w[7])) ? Number(w[7]) : -Infinity;
worldRef.achievementLastInterventionAt = w[8] != null && Number.isFinite(Number(w[8])) ? Number(w[8]) : Math.max(0, Number(worldRef.time || 0) || 0);
worldRef.achievementNoDeathStartAt = w[9] != null && Number.isFinite(Number(w[9])) ? Number(w[9]) : -1;
worldRef.achievementHappyStartAt = w[10] != null && Number.isFinite(Number(w[10])) ? Number(w[10]) : -1;
worldRef.achievementEliteFewStartAt = w[11] != null && Number.isFinite(Number(w[11])) ? Number(w[11]) : -1;
const tarRows = Array.isArray(metadata.t) ? metadata.t : [];
(worldRef.tarinai || []).forEach((t, index) => {
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
t._achievementDirectFeedCount = Math.max(0, Math.floor(Number(row[0] || 0) || 0));
t._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(row[1] || 0) || 0));
t._achievementFightMochiAt = null;
t._achievementFightMochiSerial = 0;
t._achievementFightMochiWorld = null;
t._achievementSaunaHotAt = row[3] != null && Number.isFinite(Number(row[3])) ? Number(row[3]) : null;
t._achievementNaturalSlaveLineageDepth = Math.max(0, Math.floor(Number(row[4] || 0) || 0));
t._achievementNaturalKingLineageDepth = Math.max(0, Math.floor(Number(row[5] || 0) || 0));
});
const itemRows = Array.isArray(metadata.i) ? metadata.i : [];
(worldRef.items || []).forEach((item, index) => {
@ -333,8 +481,7 @@
const fear = Number(rel?.fear) || 0;
const wins = Math.min(15, Math.max(0, Math.round(Number(rel?.fightsWon) || 0)));
const losses = Math.min(15, Math.max(0, Math.round(Number(rel?.fightsLost) || 0)));
if (Math.abs(affinity) < 5 && fear < 5 && wins + losses < 1) continue;
out.push([idx, q(affinity, 2), q(fear, 2), wins | (losses << 4)]);
out.push([idx, q(affinity, 2), q(fear, 2), wins | (losses << 4), String(rel?.lastEvent || ""), q(rel?.lastTime, 10)]);
}
return out;
}
@ -350,8 +497,8 @@
fear: u(row[2], 2),
fightsWon: fight & 15,
fightsLost: (fight >> 4) & 15,
lastEvent: fight ? "fight" : "",
lastTime: worldRef?.time || 0,
lastEvent: String(row[4] || ""),
lastTime: u(row[5], 10, worldRef?.time || 0),
};
}
return out;
@ -365,7 +512,7 @@
const modes = [enumIndex(POWER_MODE_IDS, t.powerItemMode || ""), enumIndex(SIZE_MODE_IDS, t.sizeItemMode || ""), enumIndex(LIFE_MODE_IDS, t.lifeItemMode || "")];
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1;
const state = t.state === "sleep" ? "sleep" : "idle";
const state = STATE_IDS.includes(String(t.state || "")) ? String(t.state) : "idle";
let targetIdx = -1;
if (state === "sleep") {
const targetId = t.sleepSession?.targetId || t.pendingGrassBedWakeId || t.target?.id || "";
@ -427,7 +574,9 @@
t.sizeItemMode = enumValue(SIZE_MODE_IDS, modes[1], "");
t.lifeItemMode = enumValue(LIFE_MODE_IDS, modes[2], "");
t.__savedPinIdx = row[15]; t.__savedNestIdx = row[16]; t.__savedStateInfo = row[17];
const savedEnergy = u(row[7], 1);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
t.energy = Math.max(0, Math.min(Number(t.maxEnergy) || 240, savedEnergy));
return t;
}
function itemExtra(item, tarinaiIndex = new Map(), itemIndex = new Map()) {
@ -451,8 +600,8 @@
]);
return base;
}
if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0];
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0, q(item.grassStage, 1), item.burning ? 1 : 0, q(item.burnTimer, 10)];
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100), q(item.stageTimer, 10), q(item.age, 10), item.burning ? 1 : 0, q(item.burnTimer, 10)];
if (type === "signboard") return [item.text || ""];
if (type === "robot_cleaner") return [Math.max(0, Math.min(0x7f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0];
if (type === "circuit_board") return [JSON.stringify(global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || item.circuitConfig || {})];
@ -565,9 +714,12 @@
}
if (type === "grass") {
item.growth = u(extra[0], 100, item.growth || 0); item.health = u(extra[1], 100, item.health || 1); item.fertilityBoost = u(extra[2], 100, 0); item.manualGrass = !!extra[3]; item.placedByPlayer = !!extra[3];
item.grassStage = u(extra[4], 1, global.grassStageFromGrowth?.(item.growth, item.amount) || 0);
item.burning = Boolean(extra[5]); item.burnTimer = u(extra[6], 10, 0);
global.TarinaiGrass.normalize(item);
} else if (type === "zunchi") {
item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1);
item.stageTimer = u(extra[3], 10, 0); item.age = u(extra[4], 10, item.age || 0); item.burning = Boolean(extra[5]); item.burnTimer = u(extra[6], 10, 0);
} else if (type === "signboard") {
item.text = String(extra[0] || "");
} else if (type === "robot_cleaner") {
@ -673,6 +825,7 @@
i: itemRows,
r: residueRows,
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords, options),
x: compactSnapshotExtension(worldRef, tarinaiLive, itemRecords),
};
}
function restoreSnapshot(snapshot, worldRef = global.world) {
@ -763,11 +916,11 @@
if (typeof updateNeedsRuntime === "function") {
for (const t of worldRef.tarinai) updateNeedsRuntime(t, worldRef, 0);
}
const restoredExtendedState = applySnapshotExtension(snapshot.x || null, worldRef);
// Velocity is not serialized. Start restored bodies from a neutral physical
// state, but do not grant a timed collision/damage immunity period.
// Legacy snapshots without extended state start from a neutral physical state.
delete worldRef._restoreSettleUntil;
for (const t of worldRef.tarinai) {
if (!restoredExtendedState) for (const t of worldRef.tarinai) {
if (!t || t.dead) continue;
t.vx = 0;
t.vy = 0;
@ -790,9 +943,11 @@
worldRef.familyTreeDirty = true;
worldRef.drawListDirty = true;
worldRef.terrainDirty = true;
worldRef.relationNotices = {};
worldRef.resolvedFightIds = {};
worldRef.eventCounters = {};
if (!restoredExtendedState) {
worldRef.relationNotices = {};
worldRef.resolvedFightIds = {};
worldRef.eventCounters = {};
}
worldRef.rebuildTarinaiCountCache?.("restore");
worldRef.updateItemCounts?.();
worldRef.enforceGrassLimit?.("load-grass-limit");
@ -814,4 +969,10 @@
createSnapshot,
restoreSnapshot,
};
if (global.__TARINAI_TEST__) {
global.TarinaiSnapshotTestHooks = Object.freeze({
compactAchievementMetadata,
applyAchievementMetadata,
});
}
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -24,6 +24,7 @@
this.amount = this.hp;
this.seed = Math.random() * 1000;
this.createdAt = Number.isFinite(Number(world?.time)) ? Number(world.time) : 0;
this.age = 0;
this.roles = clonePlain(definition.roles);
this.needEffects = clonePlain(definition.needEffects);
this.carriedById = definition.type === "plushie" ? this.ownerId : "";

View file

@ -53,6 +53,10 @@
return "food";
}
function randomRange(min, max) {
return min + Math.random() * (max - min);
}
function spawnFoodGiftHearts(tarinai, nutrition = 0) {
const worldRef = tarinai?.world;
if (!worldRef?.effects || typeof global.TarinaiEffect !== "function") return false;
@ -61,12 +65,12 @@
for (let i = 0; i < count; i += 1) {
const side = i % 2 ? 1 : -1;
worldRef.queueEffect?.("heart",
tarinai.x + side * global.rand(radius * 0.06, radius * 0.82),
tarinai.y - global.rand(radius * 0.38, radius * 1.72), {
vx: side * global.rand(9, 30) + global.rand(-9, 9),
vy: global.rand(-76, -30),
size: global.rand(9, 17),
life: global.rand(0.82, 1.36),
tarinai.x + side * randomRange(radius * 0.06, radius * 0.82),
tarinai.y - randomRange(radius * 0.38, radius * 1.72), {
vx: side * randomRange(9, 30) + randomRange(-9, 9),
vy: randomRange(-76, -30),
size: randomRange(9, 17),
life: randomRange(0.82, 1.36),
color: "rgba(240,91,135,0.94)",
});
}
@ -129,8 +133,9 @@
if (foodGift) {
tarinai.affection = Number(tarinai.affection || 0) + 1.8 + Math.max(0, Number(nutrition) || 0) * 0.035;
applyFoodGiftPersonality(tarinai, nutrition);
spawnFoodGiftHearts(tarinai, nutrition);
tarinai.bubble?.("\u2661", 2.2, "rgba(190,82,132,0.80)");
// Cosmetic feedback must never interrupt consumption, removal, or achievement recording.
try { spawnFoodGiftHearts(tarinai, nutrition); } catch (_) {}
try { tarinai.bubble?.("\u2661", 2.2, "rgba(190,82,132,0.80)"); } catch (_) {}
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
} else {
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);

View file

@ -80,9 +80,6 @@
const isNear = isVisible || Boolean(Geometry.pointInRect(tarinai.x || 0, tarinai.y || 0, farRect));
const motionNeed = hasMotionNeed(tarinai);
const perfProfile = global.TarinaiPerf?.performanceProfile?.();
if (perfProfile?.simulationThrottled === false) {
return { interval: 0, lane: "quality-high", smoothMotion: false, visible: isVisible, near: isNear, urgent: false };
}
const nestEntryActive = Boolean(tarinai.insideNestBoxId && (typeof tarinai.nestBoxEntryActive === "function" ? tarinai.nestBoxEntryActive() : ((worldRef.time || 0) < (tarinai._nestBoxEntrySmoothUntil || -Infinity) || ((tarinai.nestFade || 0) > 0.01 && (tarinai.nestFade || 0) < 0.985))));
if (nestEntryActive) return { interval: 0, lane: "nest-entry", smoothMotion: false, visible: true, urgent: true };
@ -92,9 +89,38 @@
(tarinai.pokeFlashTimer || 0) > 0.03 ||
(tarinai.entryTimer || 0) > 0.01 ||
(tarinai.fallTimer || 0) > 0.01 ||
/fight|knockback|danger|hurt|ant_attack|cursor_enemy|explosion/i.test(id);
/knockback|danger|hurt|ant_attack|cursor_enemy|explosion/i.test(id);
if (hardRealtimeUrgent) return { interval: 0, lane: "urgent", smoothMotion: false, visible: isVisible, urgent: true };
// Fighting used to run the complete AI/environment/health pipeline for every
// participant on every rendered frame. Dense conflict presets then formed a
// positive feedback loop of fights, effects and collision work. Keep motion
// smooth, but quantize the expensive full pipeline to a bounded cadence.
const fightUrgent = state === "fight" || (tarinai.fightTimer || 0) > 0.04 || /fight/i.test(id);
if (fightUrgent) {
// Even the full-quality profile must not execute the entire dense-fight
// pipeline once per rendered frame. It gets a tighter cadence than the
// throttled profiles, while visible motion remains interpolated.
const highQuality = perfProfile?.simulationThrottled === false;
const baseInterval = highQuality
? (isVisible ? 0.035 : (isNear ? 0.07 : 0.14))
: (isVisible ? 0.06 : (isNear ? 0.12 : 0.24));
const interval = baseInterval
* Math.max(1, Number(perfProfile?.creatureCadenceScale || 1));
return {
interval,
lane: highQuality ? "fight-quality-high" : "fight",
smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)),
visible: isVisible,
near: isNear,
urgent: true,
};
}
if (perfProfile?.simulationThrottled === false) {
return { interval: 0, lane: "quality-high", smoothMotion: false, visible: isVisible, near: isNear, urgent: false };
}
// Panic movement must stay visually continuous, but the full creature
// pipeline does not need to run every rendered frame. Treat panic as
// a high-priority motion lane: AI/timers are quantized, motion-only still

View file

@ -671,10 +671,10 @@ function bindUI() {
clearSelectedToolSilently();
}
});
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); ui.creditsDialog?.classList.remove("hidden"); });
ui.creditsCloseBtn?.addEventListener("click", () => ui.creditsDialog?.classList.add("hidden"));
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); window.TarinaiManagedDialogs?.open?.(ui.creditsDialog, ui.topMenuBtn || document.activeElement); });
ui.creditsCloseBtn?.addEventListener("click", () => window.TarinaiManagedDialogs?.close?.(ui.creditsDialog));
ui.creditsDialog?.addEventListener("click", (e) => {
if (e.target === ui.creditsDialog) ui.creditsDialog.classList.add("hidden");
if (e.target === ui.creditsDialog) window.TarinaiManagedDialogs?.close?.(ui.creditsDialog);
});
ui.visualSettingsBtn?.addEventListener("click", (e) => {
e.stopPropagation();
@ -729,6 +729,10 @@ function bindUI() {
ui.archiveResetDialog?.addEventListener("click", (e) => {
if (e.target === ui.archiveResetDialog) closeArchiveResetDialog();
});
ui.fieldDialog?.addEventListener("tarinai:dialog-close-request", closeFieldDialog);
ui.ecologyDialog?.addEventListener("tarinai:dialog-close-request", closeEcologyDialog);
ui.archiveResetDialog?.addEventListener("tarinai:dialog-close-request", closeArchiveResetDialog);
ui.creditsDialog?.addEventListener("tarinai:dialog-close-request", () => window.TarinaiManagedDialogs?.close?.(ui.creditsDialog));
ui.archiveResetConfirmBtn?.addEventListener("click", () => {
world.resetFamilyTree?.();
window.resetArchiveRenderState?.();
@ -865,7 +869,7 @@ function bindUI() {
if (e.key !== "Escape") return;
closeFieldDialog();
closeEcologyDialog();
closeVisualSettingsDialog();
closeVisualSettingsPanel();
closeArchiveResetDialog();
ui.creditsDialog?.classList.add("hidden");
});

View file

@ -52,7 +52,7 @@
if (typeof showToast === "function") showToast("\u5730\u9762\u3092\u307e\u3060\u5207\u308a\u66ff\u3048\u3089\u308c\u307e\u305b\u3093\u3002");
return;
}
global.audio?.uiClick?.();
global.TarinaiAudio?.uiClick?.();
currentWorld.cycleGroundType();
global.TarinaiRender.invalidateRenderCaches("ground-cycle");
if (global.uiCache) {

View file

@ -1,5 +1,77 @@
"use strict";
const managedDialogOrigins = new WeakMap();
function managedDialogFocusable(dialog) {
if (!dialog) return [];
return [...dialog.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(el => !el.hidden && el.getClientRects().length > 0);
}
function openManagedDialog(dialog, opener = document.activeElement) {
if (!dialog) return;
if (opener instanceof HTMLElement && !dialog.contains(opener)) managedDialogOrigins.set(dialog, opener);
if (!dialog.hasAttribute("tabindex")) dialog.tabIndex = -1;
dialog.classList.remove("hidden");
dialog.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => {
const focusables = managedDialogFocusable(dialog);
(focusables[0] || dialog).focus?.({ preventScroll: true });
});
}
function closeManagedDialog(dialog) {
if (!dialog) return;
dialog.classList.add("hidden");
dialog.setAttribute("aria-hidden", "true");
const origin = managedDialogOrigins.get(dialog);
managedDialogOrigins.delete(dialog);
if (origin?.isConnected) requestAnimationFrame(() => origin.focus?.({ preventScroll: true }));
}
function managedDialogVisible(dialog) {
if (!dialog || dialog.hidden || dialog.getAttribute("aria-hidden") === "true") return false;
if (dialog.classList.contains("hidden") || dialog.closest(".hidden")) return false;
return dialog.getClientRects().length > 0;
}
function topVisibleDialog() {
const dialogs = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')].filter(managedDialogVisible);
return dialogs[dialogs.length - 1] || null;
}
document.addEventListener("keydown", (event) => {
const dialog = topVisibleDialog();
if (!dialog) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dialog.dispatchEvent(new CustomEvent("tarinai:dialog-close-request"));
return;
}
if (event.key !== "Tab") return;
const focusables = managedDialogFocusable(dialog);
if (!focusables.length) {
event.preventDefault();
dialog.focus?.();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (!dialog.contains(document.activeElement)) {
event.preventDefault();
first.focus();
}
}, true);
window.TarinaiManagedDialogs = Object.freeze({ open: openManagedDialog, close: closeManagedDialog });
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perfScale = window.TarinaiPerf.dprScale();
@ -68,11 +140,11 @@ function openFieldDialog() {
uiCache.resetFieldType = FIELD_TYPES?.[world?.fieldType]?.id || uiCache.resetFieldType || "garden";
uiCache.resetPreset = normalizeResetPresetId(uiCache.resetPreset || "default");
syncFieldDialogChoices();
ui.fieldDialog?.classList.remove("hidden");
openManagedDialog(ui.fieldDialog);
}
function closeFieldDialog() {
ui.fieldDialog?.classList.add("hidden");
closeManagedDialog(ui.fieldDialog);
}
function hydrateLazyImages(root) {
@ -96,19 +168,19 @@ function renderEcologyCards() {
function openEcologyDialog() {
renderEcologyCards();
hydrateLazyImages(ui.ecologyDialog);
ui.ecologyDialog?.classList.remove("hidden");
openManagedDialog(ui.ecologyDialog);
}
function closeEcologyDialog() {
ui.ecologyDialog?.classList.add("hidden");
closeManagedDialog(ui.ecologyDialog);
}
function openArchiveResetDialog() {
ui.archiveResetDialog?.classList.remove("hidden");
openManagedDialog(ui.archiveResetDialog);
}
function closeArchiveResetDialog() {
ui.archiveResetDialog?.classList.add("hidden");
closeManagedDialog(ui.archiveResetDialog);
}
function resetWithField(fieldType, presetId = uiCache.resetPreset || "default") {

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "39.16.137";
const APP_BUILD = "tarinai-ui-v39-16-137";
const APP_VERSION = "39.18.3";
const APP_BUILD = "tarinai-ui-v39-18-3";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -102,20 +102,14 @@ class World { constructor() {
this.hoverPokeTarget = null;
this.deadCount = 0;
this.birthEventCount = 0;
this.achievementNaturalSlaveGenerations = [];
this.achievementNaturalKingGenerations = [];
this.achievementPlayerPlacementCount = 0;
this.achievementPlayerPlacedActiveCount = 0;
this.achievementManualTarinaiAddedCount = 0;
this.achievementDirectFeedCount = 0;
this.achievementRobotCleanCount = 0;
this.achievementEnemyAntKills = 0;
this.achievementLastTarinaiDamageAt = -Infinity;
this.achievementSelfSufficientStartAt = -1;
this.achievementLastDirectFeedAt = -Infinity;
this.achievementLastInterventionAt = 0;
this.achievementNoDeathStartAt = -1;
this.achievementHappyStartAt = -1;
this.achievementEliteFewStartAt = -1;
this.achievementFightSessionSerial = 0;
this.liveIdNext = 1;
this.liveIdSerial = 0;
this.liveTarinai = new Map();

View file

@ -5,12 +5,12 @@
if (!events) return;
events.on("world:phase", () => {
global.audio?.phase?.();
global.TarinaiAudio?.phase?.();
});
function playLogSoundForEntry(entry = {}) {
if (!entry || entry.hiddenFromObservation || entry.sound === false || entry.silentAudio) return;
const audio = global.audio;
const audio = global.TarinaiAudio;
if (!audio) return;
const kind = entry.kind || "note";
const text = String(entry.text || "");
@ -50,6 +50,6 @@
events.on("tool:placed", (event) => {
const detail = event.detail || {};
if (detail.dropped) global.audio?.place?.(detail.type || detail.item?.type || "");
if (detail.dropped) global.TarinaiAudio?.place?.(detail.type || detail.item?.type || "");
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -16,10 +16,34 @@
dialog.id = id;
dialog.className = className;
dialog.innerHTML = html;
const panel = dialog.querySelector('[role="dialog"], [role="alertdialog"]');
dialog.setAttribute("role", panel?.getAttribute("role") || "dialog");
dialog.setAttribute("aria-modal", panel?.getAttribute("aria-modal") || "true");
const labelledBy = panel?.getAttribute("aria-labelledby");
const describedBy = panel?.getAttribute("aria-describedby");
if (labelledBy) dialog.setAttribute("aria-labelledby", labelledBy);
if (describedBy) dialog.setAttribute("aria-describedby", describedBy);
panel?.removeAttribute("role");
panel?.removeAttribute("aria-modal");
panel?.removeAttribute("aria-labelledby");
panel?.removeAttribute("aria-describedby");
dialog.setAttribute("aria-hidden", dialog.classList.contains("hidden") ? "true" : "false");
document.body.appendChild(dialog);
return dialog;
}
function showEditorDialog(dialog, opener = document.activeElement) {
if (!dialog) return;
if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dialog, opener);
else { dialog.classList.remove("hidden"); dialog.setAttribute("aria-hidden", "false"); }
}
function hideEditorDialog(dialog) {
if (!dialog) return;
if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dialog);
else { dialog.classList.add("hidden"); dialog.setAttribute("aria-hidden", "true"); }
}
function bindEditorClose(dialog, close, selectors = []) {
if (!dialog || typeof close !== "function") return;
for (const selector of selectors) {
@ -27,6 +51,9 @@
if (button) button.onclick = close;
}
dialog.onclick = (event) => { if (event.target === dialog) close(); };
if (dialog._tarinaiCloseRequestHandler) dialog.removeEventListener("tarinai:dialog-close-request", dialog._tarinaiCloseRequestHandler);
dialog._tarinaiCloseRequestHandler = close;
dialog.addEventListener("tarinai:dialog-close-request", close);
}
function ensureSignboardEditor() {
@ -91,7 +118,7 @@
const clear = dialog.querySelector("#signboardEditorClear");
const closeBtn = dialog.querySelector("#signboardEditorClose");
const close = () => {
dialog.classList.add("hidden");
hideEditorDialog(dialog);
};
const syncCount = () => {
if (!area || !count) return;
@ -148,7 +175,7 @@
if (apply) apply.onclick = () => commit(area?.value || "");
if (clear) clear.onclick = () => commit("");
bindEditorClose(dialog, close, ["#signboardEditorCancel", "#signboardEditorClose"]);
dialog.classList.remove("hidden");
showEditorDialog(dialog);
syncCount();
return true;
}
@ -200,7 +227,7 @@
for (const input of inputs) input.checked = Boolean((Number(mask) | 0) & (bits[input.dataset.robotTarget] || 0));
};
const close = () => {
dialog.classList.add("hidden");
hideEditorDialog(dialog);
};
const commit = (mask) => {
api?.setRobotCleanerMask?.(robot, mask);
@ -220,7 +247,7 @@
bindEditorClose(dialog, close, ["#robotCleanerEditorClose", "#robotCleanerCancel"]);
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
if (apply) apply.onclick = () => commit(readMask());
dialog.classList.remove("hidden");
showEditorDialog(dialog);
return true;
}
@ -291,7 +318,7 @@
syncEditorInputPair(center, centerNum);
syncEditorInputPair(swingRange, swingRangeNum);
syncEditorInputPair(swingSpeed, swingSpeedNum);
const close = () => dialog.classList.add("hidden");
const close = () => hideEditorDialog(dialog);
const commit = (forceStop = false) => {
global.TarinaiHistory.capture(worldRef, "fan-swing-edit");
const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0));
@ -314,7 +341,7 @@
if (stop) stop.onclick = () => commit(true);
if (apply) apply.onclick = () => commit(false);
bindEditorClose(dialog, close, ["#fanEditorCancel", "#fanEditorClose"]);
dialog.classList.remove("hidden");
showEditorDialog(dialog);
return true;
}
@ -419,7 +446,7 @@
editor.syncInputPair(speed, speedNum, controller.draw);
editor.syncInputPair(thick, thickNum, controller.draw);
const close = () => {
dialog.classList.add("hidden");
hideEditorDialog(dialog);
controller.close();
};
if (apply) apply.onclick = () => {
@ -447,7 +474,7 @@
};
bindEditorClose(dialog, close, ["#rotatorCancel", "#rotatorEditorClose"]);
controller.draw();
dialog.classList.remove("hidden");
showEditorDialog(dialog);
return true;
}
@ -557,7 +584,7 @@
editor.syncInputPair(travel, travelNum, controller.draw);
editor.syncInputPair(thick, thickNum, controller.draw);
const close = () => {
dialog.classList.add("hidden");
hideEditorDialog(dialog);
controller.close();
};
if (apply) apply.onclick = () => {
@ -590,7 +617,7 @@
};
bindEditorClose(dialog, close, ["#reciprocatorCancel", "#reciprocatorEditorClose"]);
controller.draw();
dialog.classList.remove("hidden");
showEditorDialog(dialog);
return true;
}
@ -1002,7 +1029,7 @@
bindNearestThumbTeleport(timeRange, timeStart, timeEnd, value => normalizeMinute(value, 0), updateTimeRange);
target.onchange = () => updateMode(true);
updateMode(false);
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
const close = () => { hideEditorDialog(dialog); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
dialog.querySelector("#signalItemEditorApply").onclick = () => {
global.TarinaiHistory.capture(worldRef, "detector-edit");
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
@ -1016,10 +1043,8 @@
item.pressureTimeEnd = normalizeMinute(timeEnd.value, 1080);
worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("detector-edit"); global.render?.(); close();
};
dialog.querySelector("#signalItemEditorCancel").onclick = close;
dialog.querySelector("#signalItemEditorClose").onclick = close;
dialog.onclick = e => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
bindEditorClose(dialog, close, ["#signalItemEditorCancel", "#signalItemEditorClose"]);
showEditorDialog(dialog);
return true;
}

27
manifest.webmanifest Normal file
View file

@ -0,0 +1,27 @@
{
"id": "./",
"name": "たりない観察",
"short_name": "たりない観察",
"description": "たりないたちの暮らしを観察・操作する実験ゲーム",
"lang": "ja",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#dfe9df",
"theme_color": "#244d3a",
"icons": [
{
"src": "assets/ui/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/ui/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

24
router.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
// Router for PHP's built-in development server. Apache protects the legacy
// directory with .htaccess, but the built-in server ignores it completely.
$uri = rawurldecode((string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/'));
$segments = array_values(array_filter(explode('/', str_replace('\\', '/', $uri)), static fn(string $part): bool => $part !== ''));
foreach ($segments as $segment) {
if (str_starts_with($segment, '.')) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo "Not found\n";
return true;
}
}
$relative = ltrim($uri, '/');
$candidate = __DIR__ . ($relative === '' ? DIRECTORY_SEPARATOR . 'index.html' : DIRECTORY_SEPARATOR . $relative);
if (is_file($candidate)) return false;
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo "Not found\n";
return true;

View file

@ -1,22 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = file => fs.readFileSync(path.join(root, file), "utf8");
let failed = 0;
function ok(cond, msg) { if (!cond) { failed++; console.error(`FAIL: ${msg}`); } else console.log(`OK: ${msg}`); }
const achievements = read("js/achievements.js");
const audio = read("js/audio.js");
const pack = read("js/sound_pack.js");
const family = read("js/world_family_social.js");
const data = read("js/data.js");
ok(fs.existsSync(path.join(root, "assets/sounds/achievement_unlock.mp3")), "achievement unlock audio asset exists");
ok(pack.includes('sfx_achievement_unlock: "assets/sounds/achievement_unlock.mp3"'), "achievement audio is registered as a sample");
ok(pack.includes('sfx_achievement_unlock: "notify"'), "achievement audio respects the notify category");
ok(audio.includes('achievementUnlock() { this.ensure(); this.play("sfx_achievement_unlock"'), "achievement audio initializes samples before playback");
ok(achievements.includes('global.TarinaiAudio?.achievementUnlock?.();'), "achievement toast triggers the attached audio");
const tooltipBody = family.slice(family.indexOf('pointerItemTooltipInfo() {'), family.indexOf('startBirthRitual', family.indexOf('pointerItemTooltipInfo() {')));
ok(tooltipBody.indexOf('pointerObjectTooltipInfoLocal(this)') >= 0 && tooltipBody.indexOf('pointerObjectTooltipInfoLocal(this)') < tooltipBody.indexOf('pointerTarinaiTooltipInfoLocal(this)'), "object tooltip resolution runs before Tarinai tooltip resolution");
ok(family.includes('TarinaiCollisionFootprints?.hitTestItem'), "object tooltip priority uses precise item hit testing");
ok(/reproductionCooldown:\s*30\b/.test(data), "reproduction base cooldown is 30 seconds");
if (failed) process.exit(1);

View file

@ -1,709 +0,0 @@
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const source = fs.readFileSync(path.join(__dirname, "..", "js", "achievements.js"), "utf8");
class ClassList {
constructor() { this.values = new Set(["hidden"]); }
add(...names) { names.forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
const next = force === undefined ? !this.values.has(name) : Boolean(force);
if (next) this.values.add(name); else this.values.delete(name);
return next;
}
}
class Element {
constructor(id = "", tagName = "div") {
this.tagName = String(tagName || "div").toUpperCase();
this.open = false;
this.id = id;
this.className = "";
this.classList = new ClassList();
this.dataset = {};
this.styles = new Map();
this.style = {
setProperty: (name, value) => this.styles.set(name, value),
removeProperty: name => this.styles.delete(name),
};
this.children = [];
this.textContent = "";
this.tabIndex = -1;
this.isConnected = true;
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute() {}
addEventListener() {}
focus() {}
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
get offsetWidth() { return 100; }
}
function createHarness(existingStorage = null, search = "", options = {}) {
const storage = existingStorage || new Map();
const elements = new Map();
const ids = [
"achievementsBtn", "achievementButtonCount", "achievementsDialog", "achievementDialogCount",
"achievementsCloseBtn", "achievementsCloseIconBtn", "achievementRefreshBtn", "achievementResetBtn", "achievementUnlockAllBtn",
"achievementList", "achievementSharedStatus", "achievementToast", "achievementToastTitle", "pauseBtn",
];
ids.forEach(id => elements.set(id, new Element(id)));
elements.get("achievementsDialog").classList.add("hidden");
elements.get("achievementToast").classList.add("hidden");
let timerId = 0;
const context = {
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise,
Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
location: { protocol: options.protocol || "file:", search },
crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
removeItem(key) { storage.delete(key); },
},
document: {
hidden: false,
getElementById(id) { return elements.get(id) || null; },
createElement(tagName) { return new Element("", tagName); },
addEventListener() {},
},
TarinaiEvents: { emit() {} },
showToast() {},
audio: { uiClick() {} },
setTimeout() { timerId += 1; return timerId; },
clearTimeout() {},
setInterval() { timerId += 1; return timerId; },
clearInterval() {},
fetch: options.fetch || (async () => { throw new Error("offline"); }),
TARINAI_VERSION: "39.16.65",
TarinaiGameDialogs: { confirm: async () => true },
};
context.window = context;
context.globalThis = context;
vm.createContext(context);
vm.runInContext(source, context, { filename: "achievements.js" });
return { context, api: context.TarinaiAchievements, storage, elements };
}
function assert(condition, message) { if (!condition) throw new Error(message); }
function unlocked(h, id) { return h.api.isUnlocked(id); }
const expectedIds = [
"first_birth", "natural_zunchi_slave", "natural_tarinai_king", "self_zunchi_death",
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
"death_50_in_10_seconds", "birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
"soccer_ball_death", "fight_pair_danger_kill", "all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
"ignite_during_birth_ritual", "laxative_starvation", "idle_observer_5_minutes",
"placed_objects_100", "mechanized_industry", "safe_colony_25_5_minutes",
"ants_alive_25", "ants_killed_100", "secret_collection_9_slots",
"pause_spam_4_in_1_second", "continuous_play_1_hour", "below_absolute_zero_item",
"undo_mass_revival", "robot_cleaner_100", "held_30_seconds", "minimalist_happy",
"overprotective", "self_sufficient", "unplanned_city_30", "sauna_cold_plunge",
"rain_shelter_all", "medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
"revolution", "fuel_to_fire", "undo_20", "redo_20",
"eternal_history_generation_10", "king_full_satisfaction", "slave_zero_satisfaction", "town_doctor_50", "poke_plushie_fling",
"chaos_seeker_666_fights", "clean_freak_robot_only", "true_tarinai_observer",
"sniper_333_shots", "megalopolis", "fertility_seeker_721_love_births",
"low_fps_single_digit", "continuous_play_24_hours", "sandbox_five_toilets",
"park_ground_changed", "ground_change_4_in_1_second",
"ant_nest_without_tarinai", "sticky_bomb_15_passes",
"daily_play_7_days", "wire_shock_7_tarinai", "information_industry",
"strength_in_numbers", "elite_few", "zunchi_overflow", "comfortable_beds",
"stone_pillow", "across_seasons", "favorite_one", "statistician",
"well_informed", "lively_making", "memento_mori", "mad_scientist",
];
(async function run() {
let h = createHarness();
let storage;
assert(h.api.definitions.length === 76, "definition count must be 76");
{
const spellHarness = createHarness();
spellHarness.api.unlock("statistician");
const spell = spellHarness.api.exportSpellState();
assert(spell[0] === 8, "current achievement spell version must be 8");
assert(spell[3] === 128, "achievement 72 is not stored in the extended mask");
spellHarness.api.unlock("memento_mori");
const extendedSpell = spellHarness.api.exportSpellState();
assert((extendedSpell[3] & 0x0400) !== 0, "achievement 75 is not stored in the second extended mask byte");
spellHarness.api.unlock("mad_scientist");
const latestSpell = spellHarness.api.exportSpellState();
assert((latestSpell[3] & 0x0800) !== 0, "achievement 76 is not stored in the extended mask");
const oldSpellHarness = createHarness();
const imported = oldSpellHarness.api.importSpellState([7, 1, 0, 0, 100, [0], []]);
assert(!imported.included && !oldSpellHarness.api.isUnlocked("first_birth"), "obsolete achievement spell version was accepted");
}
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
const categorizedIds = h.api.categories.flatMap(category => category.ids);
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
assert(h.api.categories.length === 6, "achievement categories must be reorganized into six groups");
assert(JSON.stringify(h.api.categories.map(category => category.title)) === JSON.stringify(["生態", "人口", "実験", "建築", "操作", "その他"]), "achievement category titles/order mismatch");
h.api.open();
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
assert(titles.natural_tarinai_king === "たりない王", "king title mismatch");
assert(titles.self_zunchi_death === "あぁ^~ たまらねぇぜ", "self-zunchi title mismatch");
assert(titles.natural_zunchi_slave_5_generations === "末代までの恥", "five-generation title mismatch");
assert(titles.natural_tarinai_king_3_generations === "華麗なる王族", "three-generation title mismatch");
assert(titles.death_50_in_10_seconds === "6番目の大量絶滅", "mass-extinction title mismatch");
assert(titles.birth_50_in_60_seconds === "ベビーブーム", "baby-boom title mismatch");
assert(titles.idle_observer_5_minutes === "監視カメラ", "observer title mismatch");
assert(titles.laxative_starvation === "内臓全部出た", "laxative title mismatch");
assert(titles.colony_happy === "管理された楽園", "happy colony title mismatch");
assert(titles.soccer_ball_death === "豆野、殺ッカーやろうぜ!", "soccer title mismatch");
assert(titles.ants_alive_25 === "アリの惑星", "ant planet title mismatch");
assert(titles.all_non_sleep_diseased_25 === "緊急事態宣言", "emergency title mismatch");
assert(titles.secret_collection_9_slots === "秘密のコレクション", "save-slot title mismatch");
assert(titles.pause_spam_4_in_1_second === "何やってるの?", "pause-spam title mismatch");
assert(titles.continuous_play_1_hour === "めっちゃたりない観察", "continuous-play title mismatch");
assert(titles.below_absolute_zero_item === "物理法則を下側に超越", "absolute-zero title mismatch");
assert(titles.ignite_during_birth_ritual === "燃え上がる恋", "ritual ignition title mismatch");
assert(titles.safe_colony_25_5_minutes === "安全第一", "safe colony title mismatch");
assert(titles.great_mother_1000_births === "母なる大地", "great mother title mismatch");
assert(titles.fight_pair_danger_kill === "喧嘩両成敗", "fight-pair title mismatch");
assert(titles.direct_feed_33 === "餌やりじいさん", "feeding title mismatch");
assert(titles.eternal_history_generation_10 === "悠久の歴史", "generation title mismatch");
assert(titles.king_full_satisfaction === "五体満足", "king effect title mismatch");
assert(titles.slave_zero_satisfaction === "零体満足", "slave effect title mismatch");
assert(titles.chaos_seeker_666_fights === "混沌ヲ希求スル者", "chaos seeker title mismatch");
assert(titles.clean_freak_robot_only === "潔癖症", "clean freak title mismatch");
assert(titles.true_tarinai_observer === "真・たりない観察", "completionist title mismatch");
assert(titles.sniper_333_shots === "狙撃手", "sniper title mismatch");
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility title mismatch");
assert(titles.low_fps_single_digit === "スペックがたりない", "low-fps title mismatch");
assert(titles.continuous_play_24_hours === "寝ろ", "24-hour title mismatch");
assert(titles.sandbox_five_toilets === "砂場", "sandbox title mismatch");
assert(titles.park_ground_changed === "そこ公共空間だよ?", "park-ground title mismatch");
assert(titles.ground_change_4_in_1_second === "破格の工事費用", "ground-spam title mismatch");
assert(titles.ant_nest_without_tarinai === "アリ観察", "ant observation title mismatch");
assert(titles.sticky_bomb_15_passes === "命のバトン", "sticky-bomb relay title mismatch");
assert(titles.daily_play_7_days === "毎日たりない観察", "daily observation title mismatch");
assert(titles.wire_shock_7_tarinai === "抵抗器じゃない", "wire shock title mismatch");
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king description mismatch");
assert(descriptions.self_zunchi_death === "おしり鋲を抜いた時、自分のずんちで自爆する。", "self-zunchi description mismatch");
assert(descriptions.natural_zunchi_slave_5_generations === "5代連続でずんちどれい鎖なし", "five-generation description mismatch");
assert(descriptions.natural_tarinai_king_3_generations === "3代連続でたりない王王冠なし", "three-generation description mismatch");
assert(descriptions.great_mother_1000_births.includes("3333体"), "great mother description threshold mismatch");
assert(descriptions.robot_cleaner_100.includes("200個"), "cleaner description threshold mismatch");
assert(descriptions.minimalist_happy.includes("6日目以降"), "minimalist start-day description mismatch");
assert(descriptions.chaos_seeker_666_fights.includes("けんか餅") && descriptions.chaos_seeker_666_fights.includes("666回"), "chaos seeker description mismatch");
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
assert(descriptions.idle_observer_5_minutes === "3ゲーム日、操作せず画面を見るだけ", "observer description mismatch");
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
assert(descriptions.park_ground_changed === "公園の地面を勝手に変更する。", "park-ground description mismatch");
assert(descriptions.ground_change_4_in_1_second === "地面を変えすぎて破産する。", "ground-spam description mismatch");
assert(descriptions.sauna_cold_plunge === "暑すぎる状態になったたりないを、15秒以内に寒すぎる状態にする。", "sauna description mismatch");
assert(descriptions.ant_nest_without_tarinai === "アリの巣があるが、たりないがいない。", "ant observation description mismatch");
assert(descriptions.sticky_bomb_15_passes === "粘着ボムを爆発までに15回以上パスする。", "sticky-bomb relay description mismatch");
assert(!source.includes("TarinaiTooltips"), "achievement tooltips were not removed");
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
function walk(node, out = []) { for (const child of node?.children || []) { out.push(child); walk(child, out); } return out; }
const cards = walk(h.elements.get("achievementList")).filter(node => node.dataset?.achievementId);
const firstBirthCard = cards.find(node => node.dataset.achievementId === "first_birth");
const disclosedSlaveCard = cards.find(node => node.dataset.achievementId === "natural_zunchi_slave");
const disclosedKingCard = cards.find(node => node.dataset.achievementId === "natural_tarinai_king");
const disclosedRevolutionCard = cards.find(node => node.dataset.achievementId === "revolution");
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
const livelyCard = cards.find(node => node.dataset.achievementId === "lively_making");
const disclosedSelfZunchiCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
const disclosedRainCard = cards.find(node => node.dataset.achievementId === "rain_shelter_all");
const disclosedMegalopolisCard = cards.find(node => node.dataset.achievementId === "megalopolis");
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard, livelyCard]) {
assert(card.children[1].children[0].children[1].textContent !== "", "requested pre-unlock condition is hidden");
}
for (const card of [disclosedSelfZunchiCard, disclosedRainCard, disclosedMegalopolisCard]) {
assert(card.children[1].children[0].children[1].textContent !== "", "requested pre-unlock condition is hidden");
}
const groups = h.elements.get("achievementList").children;
assert(groups.length === 6, "achievement UI must contain exactly six category groups");
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "population", "experiment", "construction", "operation", "other"]), "achievement category order mismatch");
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
const categoryTitles = h.api.categories.map(category => category.title);
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "人口", "実験", "建築", "操作", "その他"]), "achievement category titles mismatch");
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
const otherIds = h.api.categories.find(category => category.id === "other").ids;
const populationIds = h.api.categories.find(category => category.id === "population").ids;
assert(otherIds.at(-1) === "true_tarinai_observer", "completionist is not at the bottom of その他");
assert(otherIds.includes("across_seasons"), "across_seasons is not in その他");
assert(operationIds.includes("statistician") && operationIds.includes("well_informed"), "requested 操作 achievements are missing");
assert(populationIds.includes("lively_making") && populationIds.includes("memento_mori"), "population achievements are missing from 人口");
h.api.unlock("natural_zunchi_slave");
const rerenderedGroups = h.elements.get("achievementList").children;
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
const allRenderedCards = rerenderedGroups.flatMap(group => walk(group).filter(node => node.dataset?.achievementId));
assert(ecologyCards[0]?.dataset.achievementId === "natural_zunchi_slave", "unlocked achievement was not moved to the top of its category");
assert(allRenderedCards.filter(card => card.dataset.achievementId === "natural_zunchi_slave").length === 1, "unlocked achievement is duplicated across categories");
assert(await h.api.reset(), "in-game achievement reset confirmation was not accepted");
assert(!h.api.isUnlocked("natural_zunchi_slave"), "achievement reset did not clear unlocked state");
assert(source.includes('card.style.setProperty("--achievement-rate"'), "achievement percentage bar is not wired");
h = createHarness();
h.api.recordBirth({ world: { time: 0 } });
assert(unlocked(h, "first_birth"), "first birth did not unlock");
h = createHarness();
const birthWorld = { time: 0 };
for (let i = 0; i < 29; i += 1) { birthWorld.time = i; h.api.recordBirth({ world: birthWorld }); }
assert(!unlocked(h, "birth_50_in_60_seconds"), "rapid birth unlocked before 30");
birthWorld.time = 59;
h.api.recordBirth({ world: birthWorld });
assert(unlocked(h, "birth_50_in_60_seconds"), "rapid birth did not unlock at 30");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 2000; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked before 3333");
h = createHarness(storage);
for (let i = 0; i < 1332; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked at 3332");
h.api.recordBirth({ world: { time: 1 } });
assert(unlocked(h, "great_mother_1000_births"), "great mother did not persist to 3333");
h = createHarness();
const deathWorld = { time: 0 };
for (let i = 0; i < 49; i += 1) { deathWorld.time = i * 0.19; h.api.recordDeath({ world: deathWorld, reason: "accident" }); }
assert(!unlocked(h, "death_50_in_10_seconds"), "rapid death unlocked before 50");
deathWorld.time = 9.5;
h.api.recordDeath({ world: deathWorld, reason: "accident" });
assert(unlocked(h, "death_50_in_10_seconds"), "rapid death did not unlock at 50");
h = createHarness();
h.api.recordDeath({ world: { time: 1 }, tarinai: {}, reason: "天寿を全うした" });
assert(unlocked(h, "lifespan_completed"), "lifespan did not unlock");
h = createHarness();
h.api.recordDeath({ world: { time: 1 }, tarinai: { _achievementLaxativeActiveThisFrame: true }, reason: "飢餓" });
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
h = createHarness();
const slaveWorld = { tarinai: [] };
let parent = { id: "s1", parents: [], generation: 1 }; slaveWorld.tarinai.push(parent); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: parent });
for (let i = 2; i <= 5; i += 1) { const child = { id: `s${i}`, parents: [parent.id], generation: i }; slaveWorld.tarinai.push(child); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: child }); parent = child; }
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "direct slave lineage did not unlock");
h = createHarness();
const kingWorld = { tarinai: [] };
let kingParent = { id: "k1", parents: [], generation: 4 }; kingWorld.tarinai.push(kingParent); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: kingParent });
for (let i = 2; i <= 3; i += 1) { const child = { id: `k${i}`, parents: [kingParent.id], generation: 3 + i }; kingWorld.tarinai.push(child); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: child }); kingParent = child; }
assert(unlocked(h, "natural_tarinai_king_3_generations"), "direct king lineage did not unlock");
h = createHarness();
const unrelatedWorld = { tarinai: [] };
for (let i = 1; i <= 5; i += 1) { const t = { id: `u${i}`, parents: [], generation: i }; unrelatedWorld.tarinai.push(t); h.api.recordNaturalZunchiSlave({ world: unrelatedWorld, tarinai: t }); }
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "unrelated generations incorrectly counted as a lineage");
h = createHarness();
const feedWorldA = { time: 0 };
for (let i = 0; i < 32; i += 1) h.api.recordDirectFeed({ world: feedWorldA });
assert(!unlocked(h, "direct_feed_33"), "direct feed unlocked before 33");
h.api.resetWorldProgress(feedWorldA);
const feedWorldB = { time: 0 };
h.api.recordDirectFeed({ world: feedWorldB });
assert(unlocked(h, "direct_feed_33"), "direct-feed count did not survive a field reset");
h = createHarness();
const diseased = Array.from({ length: 25 }, () => ({ fightDisease: true, dead: false }));
h.api.evaluateWorld({ tarinai: diseased, ants: [], time: 0 }, { id: "relaxed" });
assert(unlocked(h, "all_non_sleep_diseased_25"), "disease colony did not unlock");
h = createHarness();
const happyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
h.api.evaluateWorld(happyWorld, { id: "happy" });
happyWorld.time = 719.99;
h.api.evaluateWorld(happyWorld, { id: "happy" });
assert(!unlocked(h, "colony_happy"), "happy colony unlocked before one day");
happyWorld.time = 720;
h.api.evaluateWorld(happyWorld, { id: "happy" });
assert(unlocked(h, "colony_happy"), "happy colony did not unlock after one day from day 6");
h = createHarness();
const interruptedHappyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
interruptedHappyWorld.time = 650;
h.api.evaluateWorld(interruptedHappyWorld, { id: "normal" });
interruptedHappyWorld.time = 700;
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
interruptedHappyWorld.time = 819.99;
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
assert(!unlocked(h, "colony_happy"), "interrupted happiness did not restart the one-day timer");
interruptedHappyWorld.time = 820;
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
assert(unlocked(h, "colony_happy"), "happy colony did not unlock one full day after restart");
h = createHarness();
h.api.recordIgnition({});
h.api.recordSelfZunchiDeath({});
h.api.recordSoccerBallDeath({});
assert(unlocked(h, "ignite_during_birth_ritual"), "ritual ignition failed");
assert(unlocked(h, "self_zunchi_death"), "self zunchi failed");
assert(unlocked(h, "soccer_ball_death"), "soccer death failed");
h = createHarness();
const dangerItem = { id: "danger-1", type: "firecracker", roles: { danger: true } };
const fighterA = { id: "a", dead: true, fightTimer: 2, fightTargetId: "b", fightTargetIds: ["b"] };
const fighterB = { id: "b", dead: false, fightTimer: 2, fightTargetId: "a", fightTargetIds: ["a"] };
const fightWorld = {
time: 5,
tarinai: [fighterA, fighterB],
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
isAlreadyFightingPair() { return true; },
};
fighterA.world = fightWorld; fighterB.world = fightWorld;
h.api.recordDamageSource(fighterA, dangerItem, { world: fightWorld });
h.api.recordDeath({ world: fightWorld, tarinai: fighterA, reason: "爆竹", fromDamage: true });
assert(!unlocked(h, "fight_pair_danger_kill"), "fight pair unlocked after only one death");
fightWorld.time = 6;
fighterB.dead = true; fighterB.fightTimer = 0; fighterB.fightTargetId = null; fighterB.fightTargetIds = [];
h.api.recordDamageSource(fighterB, dangerItem, { world: fightWorld });
h.api.recordDeath({ world: fightWorld, tarinai: fighterB, reason: "爆竹", fromDamage: true });
assert(unlocked(h, "fight_pair_danger_kill"), "fight pair danger kill did not unlock");
h = createHarness();
const falsePositiveA = { id: "fa", dead: true, fightTimer: 2, fightTargetId: "fb", fightTargetIds: ["fb"] };
const falsePositiveB = { id: "fb", dead: false, fightTimer: 2, fightTargetId: "fa", fightTargetIds: ["fa"] };
const falsePositiveWorld = {
time: 3,
tarinai: [falsePositiveA, falsePositiveB],
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
isAlreadyFightingPair() { return true; },
};
falsePositiveA.world = falsePositiveWorld; falsePositiveB.world = falsePositiveWorld;
h.api.recordDamageSource(falsePositiveA, dangerItem, { world: falsePositiveWorld });
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveA, reason: "飢餓", fromDamage: false });
falsePositiveWorld.time = 4;
falsePositiveB.dead = true;
h.api.recordDamageSource(falsePositiveB, dangerItem, { world: falsePositiveWorld });
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveB, reason: "爆竹", fromDamage: true });
assert(!unlocked(h, "fight_pair_danger_kill"), "non-lethal danger damage was misclassified as a danger kill");
h = createHarness();
const generationWorld = { time: 0, maxGeneration: 9 };
h.api.recordBirth({ world: generationWorld, child: { generation: 9, world: generationWorld } });
assert(!unlocked(h, "eternal_history_generation_10"), "generation history unlocked before generation 10");
generationWorld.maxGeneration = 10;
h.api.recordBirth({ world: generationWorld, child: { generation: 10, world: generationWorld } });
assert(unlocked(h, "eternal_history_generation_10"), "generation history did not unlock at generation 10");
h = createHarness();
const king = { dead: false, isTarinaiChampion: true, isZunchiSlave: false, powerItemMode: "protein", sizeItemMode: "", world: {} };
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "protein" });
assert(!unlocked(h, "king_full_satisfaction"), "king satisfaction unlocked without giant drug");
king.sizeItemMode = "giant_drug";
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "giant_drug" });
assert(unlocked(h, "king_full_satisfaction"), "king satisfaction did not unlock with both effects");
h = createHarness();
const slave = { dead: false, isTarinaiChampion: false, isZunchiSlave: true, powerItemMode: "niteropu", sizeItemMode: "", world: {} };
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "niteropu" });
assert(!unlocked(h, "slave_zero_satisfaction"), "slave satisfaction unlocked without dwarf drug");
slave.sizeItemMode = "dwarf_drug";
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "dwarf_drug" });
assert(unlocked(h, "slave_zero_satisfaction"), "slave satisfaction did not unlock with both effects");
h = createHarness();
const allExceptCompletionist = expectedIds.filter(id => id !== "true_tarinai_observer");
for (let i = 0; i < allExceptCompletionist.length - 1; i += 1) h.api.unlock(allExceptCompletionist[i]);
assert(!unlocked(h, "true_tarinai_observer"), "completionist unlocked before every other achievement");
h.api.unlock(allExceptCompletionist[allExceptCompletionist.length - 1]);
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
h = createHarness();
const idleWorld = { time: 360, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 60; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
assert(!unlocked(h, "placed_objects_100"), "placement unlocked before 100");
h = createHarness(storage);
for (let i = 0; i < 39; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
assert(!unlocked(h, "placed_objects_100"), "placement unlocked at 99");
h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
assert(unlocked(h, "placed_objects_100"), "placement did not persist across field reset/reload to 100");
storage = new Map();
h = createHarness(storage);
h.api.recordLinkPlaced("wire", { world: { time: 1 }, item: { type: "wire" } });
h.api.recordLinkPlaced("insulated_wire", { world: { time: 2 }, item: { type: "insulated_wire" } });
const wireProgress = JSON.parse(storage.get("tarinai_achievements_v2")).progress;
assert(wireProgress.placementCount === 2, "wire placements were not counted toward placed objects");
assert(wireProgress.linkTypes.length === 0, "wire placements incorrectly advanced mechanized link types");
h = createHarness();
h.api.recordLinkPlaced("rope", { world: { time: 1 } });
h.api.recordLinkPlaced("rod", { world: { time: 1 } });
h.api.recordLinkPlaced("spring", { world: { time: 1 } });
assert(!unlocked(h, "mechanized_industry"), "mechanized industry unlocked without signal");
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
assert(unlocked(h, "mechanized_industry"), "mechanized industry did not unlock after logical AND");
assert(!unlocked(h, "signal_automation_first") && !unlocked(h, "link_craftsman"), "removed achievements remain accessible");
h = createHarness();
const safeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
const safeWorld = { time: 0, tarinai: safeAlive, ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
safeWorld.time = 300;
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
h = createHarness();
const lifespanSafeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
const lifespanSafeWorld = { time: 100, tarinai: lifespanSafeAlive, achievementNoDeathStartAt: 0 };
h.api.recordDeath({ world: lifespanSafeWorld, reason: "天寿を全うした", tarinai: { deathReason: "天寿を全うした" } });
assert(lifespanSafeWorld.achievementNoDeathStartAt === 0, "lifespan death reset safe-colony timer");
h.api.recordDeath({ world: lifespanSafeWorld, reason: "事故", tarinai: { deathReason: "事故" } });
assert(lifespanSafeWorld.achievementNoDeathStartAt === 100, "non-lifespan death did not reset safe-colony timer");
h = createHarness();
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
h.api.evaluateWorld(antWorld, { id: "relaxed" });
assert(unlocked(h, "ants_alive_25"), "ant herd did not unlock at 25");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
h = createHarness(storage);
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
h.api.recordAntKilled({ world: {}, playerCaused: true });
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
h = createHarness();
const antObservationWorld = { tarinai: [], ants: [], items: [] };
h.api.evaluateWorld(antObservationWorld, {});
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked without an ant nest");
antObservationWorld.items.push({ type: "ant_nest", dead: false });
antObservationWorld.tarinai.push({ dead: false });
h.api.evaluateWorld(antObservationWorld, {});
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked while a tarinai was alive");
antObservationWorld.tarinai[0].dead = true;
h.api.evaluateWorld(antObservationWorld, {});
assert(unlocked(h, "ant_nest_without_tarinai"), "ant observation did not unlock with a nest and zero living tarinai");
h = createHarness();
const stickyBomb = { type: "sticky_bomb", dead: false };
for (let i = 0; i < 14; i += 1) h.api.recordStickyBombPass(stickyBomb, {});
assert(!unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay unlocked before 15 passes");
h.api.recordStickyBombPass(stickyBomb, {});
assert(unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay did not unlock at 15 passes");
h = createHarness();
h.api.recordSaveSlotsFilled({ filled: 8 });
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
h.api.recordSaveSlotsFilled({ filled: 9 });
assert(unlocked(h, "secret_collection_9_slots"), "save-slot collection did not unlock at 9");
h = createHarness();
[0, 250, 500].forEach(now => h.api.recordEmptyClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked before four clicks");
h.api.recordEmptyClick({ now: 999 });
assert(unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement did not unlock within one second");
h = createHarness();
[0, 400, 800, 1201].forEach(now => h.api.recordEmptyClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked outside one-second window");
h = createHarness();
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
h.api.recordContinuousPlay({ elapsedMs: 3600000 });
assert(unlocked(h, "continuous_play_1_hour"), "continuous play did not unlock at one hour");
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked at one hour");
h.api.recordContinuousPlay({ elapsedMs: 86399999 });
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked early");
h.api.recordContinuousPlay({ elapsedMs: 86400000 });
assert(unlocked(h, "continuous_play_24_hours"), "24-hour play did not unlock");
h = createHarness();
h.api.recordLowFps({ fps: 10 });
assert(!unlocked(h, "low_fps_single_digit"), "low-fps achievement unlocked at 10 fps");
h.api.recordLowFps({ fps: 9 });
assert(unlocked(h, "low_fps_single_digit"), "low-fps achievement did not unlock at 9 fps");
h = createHarness();
const sandboxWorld = { items: Array.from({ length: 4 }, () => ({ type: "toilet", dead: false })) };
h.api.evaluateSandbox(sandboxWorld);
assert(!unlocked(h, "sandbox_five_toilets"), "sandbox unlocked before five toilet sands");
sandboxWorld.items.push({ type: "toilet", dead: false });
h.api.evaluateSandbox(sandboxWorld);
assert(unlocked(h, "sandbox_five_toilets"), "sandbox did not unlock at five toilet sands");
h = createHarness();
h.api.recordGroundChange({ world: { fieldType: "garden" }, previous: "soil", next: "ice", now: 0 });
assert(!unlocked(h, "park_ground_changed"), "park-ground achievement unlocked outside park");
h.api.recordGroundChange({ world: { fieldType: "park" }, previous: "soil", next: "ice", now: 100 });
assert(unlocked(h, "park_ground_changed"), "park-ground achievement did not unlock in park");
h = createHarness();
const groundWorld = { fieldType: "garden" };
[0, 250, 500].forEach((now, index) => h.api.recordGroundChange({ world: groundWorld, previous: `g${index}`, next: `g${index + 1}`, now }));
assert(!unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement unlocked before four changes");
h.api.recordGroundChange({ world: groundWorld, previous: "g3", next: "g4", now: 999 });
assert(unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement did not unlock within one second");
h = createHarness();
const thermalWorld = {
time: 0, tarinai: [], ants: [], items: [{ id: "cold", x: 10, y: 20, dead: false }],
temperatureAt() { return -273.15; }, achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1,
};
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
assert(!unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement unlocked at exactly absolute zero");
thermalWorld.temperatureAt = () => -273.16;
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
assert(unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement did not unlock below absolute zero");
storage = new Map();
h = createHarness(storage, "?debug=1");
assert(h.elements.get("achievementUnlockAllBtn").hidden === false, "debug all-achievements button was not shown");
h.api.debugUnlockAll();
assert(h.api.definitions.every(def => unlocked(h, def.id)), "debug all-achievements button did not unlock all");
const debugState = JSON.parse(storage.get("tarinai_achievements_v2"));
assert(debugState.pending.length === 0, "debug unlocks were queued for shared statistics");
assert(debugState.debugUnlocked.length === h.api.definitions.length, "debug unlocks were not marked as local-only");
h = createHarness(storage, "");
assert(h.api.definitions.every(def => !unlocked(h, def.id)), "debug-only unlocks leaked into normal play");
storage = new Map();
storage.set("tarinai_achievements_v2", JSON.stringify({
unlocked: { first_birth: 1700000000123, lifespan_completed: 1700000000456 },
pending: [],
progress: {},
}));
const recoveryRequests = [];
h = createHarness(storage, "", {
protocol: "https:",
fetch: async (_url, options = {}) => {
recoveryRequests.push(JSON.parse(options.body || "{}"));
return { ok: true, async json() { return { ok: true, totalPlayers: 1, achievements: {} }; } };
},
});
await h.api.refresh();
assert(recoveryRequests.length === 1, "recovery sync did not issue exactly one request");
assert(recoveryRequests[0].action === "sync", "recovery request did not use bulk sync");
assert(recoveryRequests[0].completionistEligible === false, "full recovery sync did not report completionist eligibility");
assert(recoveryRequests[0].unlocked.first_birth === 1700000000123, "recovery sync omitted first birth timestamp");
assert(recoveryRequests[0].unlocked.lifespan_completed === 1700000000456, "recovery sync omitted lifespan timestamp");
h = createHarness();
for (let i = 0; i < 49; i += 1) h.api.recordFirstAidRecovery({ recovered: 10 });
assert(!unlocked(h, "town_doctor_50"), "doctor unlocked before 50 recoveries");
h.api.recordFirstAidRecovery({ recovered: 0 });
assert(!unlocked(h, "town_doctor_50"), "zero recovery counted for doctor");
h.api.recordFirstAidRecovery({ recovered: 1 });
assert(unlocked(h, "town_doctor_50"), "doctor did not unlock at 50 recoveries");
h = createHarness();
h.api.recordPlushiePokeFling({});
assert(unlocked(h, "poke_plushie_fling"), "plushie poke achievement did not unlock");
storage = new Map();
h = createHarness(storage);
const chaosWorld = { time: 0 };
for (let i = 0; i < 700; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
assert(!unlocked(h, "chaos_seeker_666_fights"), "ordinary fights counted toward chaos seeker");
const fightMochiTarinai = { fightMochiTimer: 10 };
for (let i = 0; i < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
h = createHarness(storage);
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 influenced fights");
h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 influenced fights");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 332; i += 1) h.api.recordShotFired({});
assert(!unlocked(h, "sniper_333_shots"), "sniper unlocked before 333 shots");
h = createHarness(storage);
h.api.recordShotFired({});
assert(unlocked(h, "sniper_333_shots"), "sniper did not persist to 333 shots");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 800; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: false });
assert(!unlocked(h, "fertility_seeker_721_love_births"), "ordinary reproduction counted toward fertility seeker");
for (let i = 0; i < 720; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
assert(!unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker unlocked before 721 influenced reproductions");
h = createHarness(storage);
h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
assert(unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker did not persist to 721 influenced reproductions");
h = createHarness();
const metroWorld = {
tarinai: Array.from({ length: 99 }, () => ({ dead: false })),
items: [
...Array.from({ length: 10 }, () => ({ type: "duplicator", dead: false })),
...Array.from({ length: 15 }, (_v, i) => ({ type: i % 3 === 0 ? "grass_bed" : (i % 3 === 1 ? "nest_box" : "pipe"), dead: false })),
],
};
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 100-tarinai threshold");
metroWorld.tarinai.push({ dead: false });
metroWorld.items[0].dead = true;
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 10-duplicator threshold");
metroWorld.items[0].dead = false;
metroWorld.items[10].dead = true;
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 15-shelter threshold");
metroWorld.items[10].dead = false;
h.api.evaluateMegalopolis(metroWorld);
assert(unlocked(h, "megalopolis"), "megalopolis did not unlock at exact thresholds");
h = createHarness();
const cleaner = { type: "robot_cleaner", dead: false, robotCleanerMask: 0x3f, robotCleanerHighSpeed: true, _signalDriven: true, _signalOn: false };
const cleanWorld = { items: [cleaner], tarinai: [], ants: [], time: 0 };
h.api.evaluateCleanFreak(cleanWorld);
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak unlocked while signal-powered cleaner was off");
cleaner._signalOn = true;
cleanWorld.tarinai.push({ dead: false });
h.api.evaluateCleanFreak(cleanWorld);
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored a living tarinai");
cleanWorld.tarinai.length = 0;
cleanWorld.items.push({ type: "grass", dead: false });
h.api.evaluateCleanFreak(cleanWorld);
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored another field item");
cleanWorld.items.pop();
h.api.evaluateCleanFreak(cleanWorld);
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
h = createHarness();
for (let i = 0; i < 9; i += 1) h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: `mad-${i}` } });
h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: "mad-0" } });
assert(!unlocked(h, "mad_scientist"), "mad scientist counted the same tarinai twice");
h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: "mad-9" } });
assert(unlocked(h, "mad_scientist"), "mad scientist did not unlock at 10 distinct tarinai");
h = createHarness();
const livelyWorldA = { time: 0, tarinai: [] };
for (let i = 0; i < 60; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldA });
h.api.resetWorldProgress(livelyWorldA);
const livelyWorldB = { time: 0, tarinai: [] };
for (let i = 0; i < 39; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldB });
assert(!unlocked(h, "lively_making"), "lively making unlocked before 100 additions across resets");
h.api.recordManualTarinaiAdded({ world: livelyWorldB });
assert(unlocked(h, "lively_making"), "lively making did not persist across field reset");
console.log("[OK] all 76 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
})().catch(error => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,128 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def read(name):
return (ROOT / name).read_text()
def require(condition, message):
if not condition:
raise AssertionError(message)
ach = read('js/achievements.js')
api = read('achievement_api.php')
css = read('css/components.css')
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
require(len(ids_js) == 77, 'JS achievement count is not 77')
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai','well_informed','lively_making','memento_mori','mad_scientist']), 'latest achievement IDs are missing')
require(all(item in ids_js for item in ['undo_mass_revival','robot_cleaner_100','held_30_seconds','minimalist_happy','overprotective','self_sufficient','unplanned_city_30','sauna_cold_plunge','rain_shelter_all','medicine_ledger_all','mercury_lifespan','enemy_enemy_friend','revolution','fuel_to_fire','undo_20','redo_20']), 'playstyle achievement IDs are missing')
checks = {
'birth event': 'recordBirth?.({ world: this, child, parents: [a, b] })' in read('js/world_family_social.js'),
'death event after final reason': 'recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) })' in read('js/tarinai_social_move_life.js'),
'natural slave event only in fight-record path': 'recordNaturalZunchiSlave?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
'natural king event only in fight-record path': 'recordNaturalTarinaiKing?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
'direct feed is food-only': 'if (foodGift) {' in read('js/tarinai_direct_feeding_system.js') and 'recordDirectFeed?.' in read('js/tarinai_direct_feeding_system.js'),
'ritual ignition checks state before mutation': 'const ignitedDuringBirthRitual = !already' in read('js/tarinai_disease_nest.js'),
'soccer unlock follows fatal ball impact': 'if (t.dead && ball.type === "ball")' in read('js/collision_response_system.js'),
'self-zunchi return collision is armed only after owner clearance': 'achievementBurstClearedOwner = false' in read('js/world_combat_effects.js') and 'updateBurstOwnerClearance(item, worldRef)' in read('js/item_dynamic_zunchi_system.js') and 'if (isBurstOwner && item.achievementBurstClearedOwner !== true) continue' in read('js/item_dynamic_zunchi_system.js'),
'self-zunchi unlock survives fatal ID release': 'const hitTarinaiIdBeforeDamage = String(t.id || t.releasedLiveId || "")' in read('js/item_dynamic_zunchi_system.js') and 'const eligibleSelfZunchiDeath = Boolean(' in read('js/item_dynamic_zunchi_system.js') and 'if (t.dead && eligibleSelfZunchiDeath)' in read('js/item_dynamic_zunchi_system.js'),
'held launched zunchi is disqualified': 'if (item.playerHeld || item._heldByPlayer)' in read('js/item_dynamic_zunchi_system.js') and 'target.achievementBurstNeverStopped = false' in read('js/ui_input_shared.js'),
'generation runs persist in saves': 'achievementNaturalSlaveGenerations' in read('js/snapshot_system.js') and 'achievementNaturalKingGenerations' in read('js/snapshot_system.js'),
'direct-feed cumulative progress is persistent': 'progress.directFeedCount' in ach and 'progress.directFeedCount + 1' in ach,
'direct-feed cumulative progress is not reset with world': 'progress.directFeedCount = 0' not in ach,
'simultaneous toast queue exists': 'let toastQueue = [];' in ach and 'runNextUnlockToast' in ach,
'achievement toast is top-left': 'top: 18px;' in css and 'left: 18px;' in css and 'right: auto;' in css,
'laxative frame-edge marker exists': '_achievementLaxativeActiveThisFrame' in read('js/tarinai_update_step_frame.js') and '_achievementLaxativeActiveThisFrame' in ach,
'disease condition excludes sleep-only': 't.zunchiDisease || t.explosionDisease || t.fightDisease' in ach,
'locked condition display policy': 'lockedDescriptionText' in ach and '\\uff1f\\uff1f\\uff1f' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach,
'condition shown beside title': 'heading.append(title, description);' in ach,
'achievement tooltips removed': 'TarinaiTooltips' not in ach and 'dataset.tip' not in ach and 'achievementTooltipText' not in ach,
'percentage background bar including unlocked cards': '--achievement-rate' in ach and '.achievement-entry::before' in css and 'width: var(--achievement-rate);' in css and '.achievement-entry > *' in css and 'z-index: 0;' in css,
'achievement category folds': 'ACHIEVEMENT_CATEGORIES' in ach and 'createAchievementGroup' in ach and 'achievement-group' in css,
'category-local unlocked ordering': 'orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]' in ach and 'id: "completed"' not in ach,
'requested pre-unlock disclosures': all(item in ach.split('const PRE_UNLOCK_DESCRIPTION_IDS', 1)[1].split(']);', 1)[0] for item in ['natural_zunchi_slave','natural_tarinai_king','revolution','laxative_starvation']),
'native browser confirmations removed': not re.search(r'\b(?:alert|confirm|prompt)\s*\(', ach + read('js/save_system.js')) and 'TarinaiGameDialogs' in ach and 'TarinaiGameDialogs' in read('js/save_system.js'),
'achiever count beside percentage': 'unlockedPlayers' in ach and 'sharedAchievementText' in ach and '\\u9054\\u6210\\u8005' in ach,
'persistent placement count': 'progress.placementCount' in ach and 'unlock("placed_objects_100"' in ach,
'mechanized logical AND': 'progress.signalActivated && hasAllLinks' in ach,
'persistent ant kill count': 'progress.antKills' in ach and 'unlock("ants_killed_100"' in ach,
'persistent reproduction count': 'progress.birthCount' in ach and 'progress.birthCount >= 3333' in ach and 'unlock("great_mother_1000_births"' in ach,
'danger damage source pipeline': 'recordDamageSource' in ach and 'options.source' in read('js/health.js') and '{ source: it }' in read('js/world_combat_effects.js') and 'type: "shoot"' in read('js/world_tool_actions.js') and '_achievementDangerSource = pin' in read('js/world_combat_effects.js') and 'recordDamageSource?.(target, home' in read('js/world_ants_system.js'),
'fight-pair danger tracker': 'recordFightPairDangerDeath' in ach and 'fight_pair_danger_kill' in ach and 'detail.fromDamage || detail.achievementDangerKill' in ach,
'debug unlock-all button': 'achievementUnlockAllBtn' in read('index.html') and 'unlockAllAchievementsForDebug' in ach and 'DEBUG_MODE' in ach and 'debugUnlocked' in ach,
'idle intervention hooks': 'recordIntervention?.({ world: this, tool: "poke"' in read('js/world_placement_log.js') and 'recordIntervention?.({ world, tool: "pinch"' in read('js/ui_input_shared.js') and 'recordIntervention?.({ world: this, tool: "shoot"' in read('js/world_tool_actions.js'),
'link placement hook': 'recordLinkPlaced?.(constraintType' in read('js/world_tool_actions.js') and 'insulated_wire' in ach and 'recordPlayerPlacement(detail);' in ach,
'signal activation hook': 'recordSignalActivation?.({ world: worldRef' in read('js/signal_system.js'),
'ant kill hook': 'recordAntKilled?.({ world: this.world, ant: this' in read('js/ants.js'),
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
'safe colony timer': 'now - safeStart >= 300' in ach,
'safe colony ignores lifespan deaths': 'lifespanDeath' in ach and '\\u5bff\\u547d|\\u5929\\u5bff' in ach,
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
'idle observer timer': 'observerTarget' in ach and 'OBSERVER_GAME_DAYS = 3' in ach,
'retired achievement migration removed': 'signal_automation_first' not in api and 'link_craftsman' not in api,
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
'empty-click hook': 'recordEmptyClick' in ach and 'recordEmptyClick?.' in read('js/world_placement_log.js') and 'recordPauseClick' not in ach and 'pauseButton?.addEventListener' not in ach,
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'CONTINUOUS_PLAY_24H_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach and 'continuousPlayStartedAt = Date.now()' in ach and 'if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now();' in ach,
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
'current-only achievement and save format': 'const SPELL_STATE_VERSION = 8;' in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach and 'version !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js') and 'schema !== 52' not in read('js/save_codec.js'),
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' in ach,
'absolute-zero item evaluation': 'ABSOLUTE_ZERO_C' in ach and 'recordBelowAbsoluteZeroItem(worldRef)' in ach and 'temperatureAt?.(item.x, item.y, null)' in ach,
'recovery sync is additive': "if (isset($state['unlocks'][$achievementId][$playerId])) continue;" in api,
'debug unlocks excluded from recovery': 'state.debugUnlocked?.includes?.(definition.id)' in ach,
'hidden achievement UI skips list rebuild': 'dom.dialog?.classList.contains("hidden")' in ach and 'achievementListSignature' in ach,
'achievement category definitions are cached': 'CATEGORY_DEFINITIONS' in ach,
'achievement sync is debounced and pending-only': 'SYNC_DEBOUNCE_MS' in ach and 'schedulePendingSync' in ach and 'pendingIds' in ach,
'completionist excludes itself': 'definition.id === COMPLETIONIST_ID' in ach and 'allOtherAchievementsUnlocked' in ach,
'generation ten is event-driven': 'generation >= GENERATION_TARGET' in ach and 'eternal_history_generation_10' in ach,
'status effect combinations are event-driven': 'evaluateStatusEffectAchievements' in ach and 'king_full_satisfaction' in ach and 'slave_zero_satisfaction' in ach,
'doctor counts actual recovery only': 'recordFirstAidRecovery' in ach and 'recovered > 0.0001' in read('js/item_effect_definitions.js'),
'plushie poke uses shared target and overlay': 'findPokeTargetAt' in read('js/world_placement_log.js') and 'hoverPokeTarget' in read('js/ui_input_shared.js') and 'recordPlushiePokeFling' in ach,
'persistent fight-mochi chaos counter': 'progress.fightMochiFightCount' in ach and 'CHAOS_FIGHT_TARGET = 666' in ach and 'fightMochiInfluenced' in ach and 'recordFightStarted' in ach,
'persistent sniper counter': 'progress.shotCount' in ach and 'SNIPER_SHOT_TARGET = 333' in ach and 'recordShotFired' in ach and 'recordShotFired?.' in read('js/world_tool_actions.js'),
'persistent love-mochi fertility counter': 'progress.loveMochiBirthCount' in ach and 'FERTILITY_BIRTH_TARGET = 721' in ach and 'recordLoveMochiBirth' in ach and 'loveMochiInfluenced' in read('js/world_family_social.js'),
'megalopolis exact thresholds': 'duplicatorCount < 10' in ach and 'shelterCount < 15' in ach and 'aliveCount < 100' in ach and 'evaluateMegalopolis' in ach,
'clean-freak robot-only evaluation': 'evaluateCleanFreak' in ach and 'ROBOT_ALL_TARGET_MASK = 0x3f' in ach and 'robotCleanerHighSpeed' in ach and 'signal-power' in read('js/signal_system.js'),
'medicine ledger excludes chain and crown': 'slave_chain' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0] and 'golden_crown' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0],
'cleaner target raised to 200': 'ROBOT_CLEAN_TARGET = 200' in ach and r'\u7d2f\u8a08200\u500b' in ach,
'minimalist starts on day six': 'day >= 6 && !state.unlocked.minimalist_happy' in ach,
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
'obsolete spell versions are rejected': 'const SPELL_STATE_VERSION = 8;' in ach and 'LEGACY_SPELL_STATE_VERSION' not in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach,
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
'summary is read-only': "$mutatesPlayer = $action !== 'summary';" in api and 'if ($mutatesPlayer)' in api,
'completionist false cannot be re-added': "unset($syncUnlocks['true_tarinai_observer'])" in api,
'GET mutations are rejected': "$method === 'GET' && $action !== 'summary'" in api,
}
for name, ok in checks.items():
require(ok, name)
social = read('js/world_family_social.js') + read('js/tarinai_social_action_runtime.js')
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
print('[OK] achievement integration audit passed')
for name in checks:
print(f'[OK] {name}')

View file

@ -1,47 +0,0 @@
"use strict";
global.window = global;
global.TarinaiSnapshot = { version: 40 };
global.TarinaiSaveSchema = {
BINARY_SCHEMA_VERSION: 53,
FIELD_IDS: ["garden"],
itemTypeValue(_index, fallback = "") { return fallback; },
};
require("../js/save_codec.js");
// first_birth + statistician + memento_mori. v8 also stores persistent lively-making progress.
// Achievements 65-75 remain in the extended mask value at spell[3].
const progress = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 9999, 99];
const spell = [8, 1, 0, 1152, 100, [0, 1, 2], progress];
const snapshot = {
v: 40,
a: "tj1",
m: [1, 0, "garden", 0],
w: [0, 0, 0, 0, 0, -9990, 1, 0, "w0000000000000000", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
t: [],
i: [],
g: {
w: [[], [], 0, 0, 0, null, -1, null, 0, -1, -1],
t: [],
i: [],
s: spell,
},
};
const bytes = global.TarinaiSaveCodec.encodeBinarySnapshot(snapshot);
const decoded = global.TarinaiSaveCodec.decodeBinarySnapshot(bytes);
const restored = decoded?.g?.s;
if (!Array.isArray(restored) || restored[0] !== 8) throw new Error("achievement spell v8 was not restored");
if (restored[3] !== 1152) throw new Error("extended achievement mask bytes were lost");
if (restored[6]?.[14] !== 29) throw new Error("statistician progress was lost");
if (restored[6]?.[15] !== 9999) throw new Error("memento mori progress was lost");
if (restored[6]?.[16] !== 99) throw new Error("lively making progress was lost");
const oldSchemaBytes = Uint8Array.from(bytes);
oldSchemaBytes[0] = (oldSchemaBytes[0] & 0x80) | 52;
let rejectedOldSchema = false;
try { global.TarinaiSaveCodec.decodeBinarySnapshot(oldSchemaBytes); } catch (_) { rejectedOldSchema = true; }
if (!rejectedOldSchema) throw new Error("obsolete binary schema was accepted");
console.log("[OK] current-only schema 53, achievement v8 mask, and persistent progress binary round-trip passed");

View file

@ -1,47 +0,0 @@
"use strict";
global.window = global;
global.TarinaiSnapshot = { version: 40 };
global.TarinaiSaveSchema = {
BINARY_SCHEMA_VERSION: 53,
FIELD_IDS: ["garden"],
itemTypeValue(_index, fallback = "") { return fallback; },
};
require("../js/save_codec.js");
// first_birth + statistician + memento_mori. Current v8 also stores persistent lively-making progress.
// Achievements 65-75 remain in the extended mask value at spell[3].
const progress = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 9999, 99];
const spell = [8, 1, 0, 1152, 100, [0, 1, 2], progress];
const snapshot = {
v: 40,
a: "tj1",
m: [1, 0, "garden", 0],
w: [0, 0, 0, 0, 0, -9990, 1, 0, "w0000000000000000", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
t: [],
i: [],
g: {
w: [[], [], 0, 0, 0, null, -1, null, 0, -1, -1],
t: [],
i: [],
s: spell,
},
};
const bytes = global.TarinaiSaveCodec.encodeBinarySnapshot(snapshot);
const decoded = global.TarinaiSaveCodec.decodeBinarySnapshot(bytes);
const restored = decoded?.g?.s;
if (!Array.isArray(restored) || restored[0] !== 8) throw new Error("achievement spell v8 was not restored");
if (restored[3] !== 1152) throw new Error("extended achievement mask bytes were lost");
if (restored[6]?.[14] !== 29) throw new Error("statistician progress was lost");
if (restored[6]?.[15] !== 9999) throw new Error("memento mori progress was lost");
if (restored[6]?.[16] !== 99) throw new Error("lively making progress was lost");
const oldSchemaBytes = Uint8Array.from(bytes);
oldSchemaBytes[0] = (oldSchemaBytes[0] & 0x80) | 52;
let rejectedOldSchema = false;
try { global.TarinaiSaveCodec.decodeBinarySnapshot(oldSchemaBytes); } catch (_) { rejectedOldSchema = true; }
if (!rejectedOldSchema) throw new Error("obsolete binary schema was accepted");
console.log("[OK] current-only schema 53, achievement v8 mask, and persistent progress binary round-trip passed");

View file

@ -1,341 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import base64
import concurrent.futures
import json
import os
from pathlib import Path
import random
import re
import shutil
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[1]
API_SOURCE = ROOT / "achievement_api.php"
def achievement_ids() -> list[str]:
source = API_SOURCE.read_text(encoding="utf-8")
match = re.search(r"const ACHIEVEMENT_IDS = \[(.*?)\n\];", source, re.S)
if not match:
raise AssertionError("ACHIEVEMENT_IDS was not found")
values = re.findall(r"'([^']+)'", match.group(1))
if len(values) != 75 or len(values) != len(set(values)):
raise AssertionError(f"expected 75 unique achievement IDs, got {len(values)}")
return values
ACHIEVEMENTS = achievement_ids()
def compact_uuid(value: str) -> str:
return base64.urlsafe_b64encode(uuid.UUID(value).bytes).decode("ascii").rstrip("=")
def expand_uuid(value: str) -> str:
return str(uuid.UUID(bytes=base64.urlsafe_b64decode(value + "==")))
def decode_v3(value: object) -> dict:
if not isinstance(value, list) or len(value) < 5 or value[0] != 3:
raise AssertionError("state is not compact v3")
base_time = int(value[1])
versions = value[2]
rows = value[3]
unlock_rows = value[4]
if not isinstance(versions, list) or not isinstance(rows, list) or not isinstance(unlock_rows, list):
raise AssertionError("invalid v3 sections")
players: dict[str, dict] = {}
player_ids: list[str] = []
for row in rows:
player_id = expand_uuid(str(row[0]))
first_seen = base_time + max(0, int(row[1] if len(row) > 1 else 0))
last_seen = first_seen + max(0, int(row[2] if len(row) > 2 else 0))
version_index = max(0, int(row[3] if len(row) > 3 else 0))
player_ids.append(player_id)
players[player_id] = {
"firstSeen": first_seen,
"lastSeen": last_seen,
"gameVersion": str(versions[version_index] if version_index < len(versions) else ""),
}
unlocks: dict[str, dict[str, int]] = {}
for row in unlock_rows:
achievement_index = int(row[0])
if achievement_index < 0 or achievement_index >= len(ACHIEVEMENTS):
continue
records: dict[str, int] = {}
for offset in range(1, len(row) - 1, 2):
player_index = int(row[offset])
if 0 <= player_index < len(player_ids):
records[player_ids[player_index]] = base_time + max(0, int(row[offset + 1]))
if records:
unlocks[ACHIEVEMENTS[achievement_index]] = records
return {"players": players, "unlocks": unlocks}
def make_random_v2(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
rng = random.Random(seed)
versions = ["39.16.60", "39.16.63", "39.16.64", "39.16.65", "dev"]
base_time = 1_780_000_000 + seed * 10_000
rows: list[list] = []
players: dict[str, dict] = {}
player_ids: list[str] = []
for index in range(player_count):
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
first_seen = base_time + rng.randint(0, 200_000)
last_seen = first_seen + rng.randint(0, 80_000)
version = rng.choices(versions, weights=[2, 4, 9, 12, 1], k=1)[0]
player_ids.append(player_id)
rows.append([player_id, first_seen, last_seen, version])
players[player_id] = {"firstSeen": first_seen, "lastSeen": last_seen, "gameVersion": version}
unlocks_flat: dict[str, list[int]] = {}
unlocks: dict[str, dict[str, int]] = {}
for achievement_id in ACHIEVEMENTS:
flat: list[int] = []
records: dict[str, int] = {}
for player_index, player_id in enumerate(player_ids):
if rng.random() >= unlock_probability:
continue
timestamp = players[player_id]["firstSeen"] + rng.randint(0, max(0, players[player_id]["lastSeen"] - players[player_id]["firstSeen"]))
flat.extend([player_index, timestamp])
records[player_id] = timestamp
if flat:
unlocks_flat[achievement_id] = flat
unlocks[achievement_id] = records
return {"v": 2, "p": rows, "u": unlocks_flat}, {"players": players, "unlocks": unlocks}
def make_random_verbose(seed: int, player_count: int, unlock_probability: float) -> tuple[dict, dict]:
v2, expected = make_random_v2(seed, player_count, unlock_probability)
players = {
row[0]: {"firstSeen": row[1], "lastSeen": row[2], "gameVersion": row[3]}
for row in v2["p"]
}
player_ids = [row[0] for row in v2["p"]]
unlocks = {
achievement_id: {player_ids[flat[i]]: flat[i + 1] for i in range(0, len(flat), 2)}
for achievement_id, flat in v2["u"].items()
}
return {"players": players, "unlocks": unlocks}, expected
def normalize_expected(value: dict) -> dict:
return {
"players": {key: value["players"][key] for key in sorted(value["players"])},
"unlocks": {
achievement_id: {key: records[key] for key in sorted(records)}
for achievement_id, records in value["unlocks"].items()
if records
},
}
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class PhpServer:
def __init__(self, root: Path):
self.root = root
self.port = free_port()
env = dict(os.environ)
env["PHP_CLI_SERVER_WORKERS"] = "8"
self.process = subprocess.Popen(
["php", "-S", f"127.0.0.1:{self.port}"],
cwd=root,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.time() + 8
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
break
except OSError:
if self.process.poll() is not None:
raise RuntimeError("PHP test server exited")
time.sleep(0.05)
else:
raise RuntimeError("PHP test server did not start")
def close(self) -> None:
if self.process.poll() is None:
os.killpg(self.process.pid, signal.SIGTERM)
try:
self.process.wait(timeout=4)
except subprocess.TimeoutExpired:
os.killpg(self.process.pid, signal.SIGKILL)
self.process.wait(timeout=4)
def request(self, *, action: str = "summary", player_id: str, payload: dict | None = None) -> tuple[int, dict]:
if payload is None:
url = f"http://127.0.0.1:{self.port}/achievement_api.php?action={action}&playerId={player_id}"
request = urllib.request.Request(url, method="GET")
else:
url = f"http://127.0.0.1:{self.port}/achievement_api.php"
data = json.dumps(payload, separators=(",", ":")).encode()
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def assert_equal_state(actual: dict, expected: dict, label: str) -> None:
actual_normalized = normalize_expected(actual)
expected_normalized = normalize_expected(expected)
if actual_normalized != expected_normalized:
raise AssertionError(f"{label}: semantic state changed")
def run_migration_cases(server: PhpServer, state_path: Path) -> tuple[int, int]:
total_original = 0
total_compact = 0
probe_id = str(uuid.uuid4())
for case_index in range(32):
player_count = 1 + (case_index * 37) % 260
probability = 0.02 + ((case_index * 11) % 55) / 100
if case_index % 5 == 0:
source, expected = make_random_verbose(10_000 + case_index, player_count, probability)
else:
source, expected = make_random_v2(10_000 + case_index, player_count, probability)
original = json.dumps(source, separators=(",", ":")).encode()
state_path.write_bytes(original)
status, summary = server.request(player_id=probe_id)
if status != 200 or not summary.get("ok"):
raise AssertionError(f"migration case {case_index} failed: {status} {summary}")
if summary.get("totalPlayers") != player_count:
raise AssertionError("read-only summary changed or miscounted players")
first_bytes = state_path.read_bytes()
decoded = decode_v3(json.loads(first_bytes))
assert_equal_state(decoded, expected, f"migration case {case_index}")
status, second_summary = server.request(player_id=probe_id)
if status != 200 or second_summary.get("totalPlayers") != player_count:
raise AssertionError("v3 reread failed")
if state_path.read_bytes() != first_bytes:
raise AssertionError("canonical v3 changed on a second read")
total_original += len(original)
total_compact += len(first_bytes)
if total_compact >= total_original * 0.72:
raise AssertionError(f"aggregate compaction is insufficient: {total_compact}/{total_original}")
return total_original, total_compact
def run_corruption_cases(server: PhpServer, state_path: Path) -> None:
probe_id = str(uuid.uuid4())
for payload in [b'{"v":2,', b'{"v":99}', b'[3,1]']:
state_path.write_bytes(payload)
status, body = server.request(player_id=probe_id)
if status != 503 or body.get("error") != "storage_corrupt":
raise AssertionError(f"corrupt state was not rejected: {status} {body}")
if state_path.read_bytes() != payload:
raise AssertionError("corrupt state was overwritten")
def run_completionist_case(server: PhpServer, state_path: Path) -> None:
state_path.write_text("", encoding="utf-8")
player_id = str(uuid.uuid4())
now_ms = int(time.time() * 1000)
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": False,
"unlocked": {"first_birth": now_ms, "true_tarinai_observer": now_ms},
})
if status != 200 or body["achievements"]["first_birth"]["unlockedPlayers"] != 1:
raise AssertionError("ordinary sync unlock was lost")
if body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 0:
raise AssertionError("ineligible completionist unlock was re-added")
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": True,
"unlocked": {"true_tarinai_observer": now_ms},
})
if status != 200 or body["achievements"]["true_tarinai_observer"]["unlockedPlayers"] != 1:
raise AssertionError("eligible completionist unlock was not accepted")
before = state_path.read_bytes()
status, body = server.request(action="reset", player_id=player_id)
if status != 405 or body.get("error") != "method_not_allowed":
raise AssertionError("GET reset was not rejected")
if state_path.read_bytes() != before:
raise AssertionError("rejected GET reset mutated state")
def run_concurrency_case(server: PhpServer, state_path: Path) -> None:
state_path.write_text("", encoding="utf-8")
rng = random.Random(52_001)
requests: list[tuple[str, dict[str, int]]] = []
now_ms = int(time.time() * 1000)
for index in range(75):
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
requests.append((player_id, unlocks))
def submit(entry: tuple[str, dict[str, int]]) -> None:
player_id, unlocks = entry
status, body = server.request(player_id=player_id, payload={
"action": "sync",
"playerId": player_id,
"gameVersion": "39.16.65",
"completionistEligible": "true_tarinai_observer" in unlocks,
"unlocked": unlocks,
})
if status != 200 or not body.get("ok"):
raise AssertionError(f"concurrent sync failed: {status} {body}")
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as executor:
list(executor.map(submit, requests))
status, summary = server.request(player_id=str(uuid.uuid4()))
if status != 200 or summary.get("totalPlayers") != len(requests):
raise AssertionError("concurrent updates lost players")
decoded = decode_v3(json.loads(state_path.read_bytes()))
if len(decoded["players"]) != len(requests):
raise AssertionError("concurrent state player count mismatch")
for player_id, unlocks in requests:
for achievement_id in unlocks:
if player_id not in decoded["unlocks"].get(achievement_id, {}):
raise AssertionError(f"concurrent update lost {achievement_id} for {player_id}")
def main() -> None:
with tempfile.TemporaryDirectory(prefix="tarinai-achievement-random-audit-") as temp_dir:
root = Path(temp_dir)
shutil.copy2(API_SOURCE, root / "achievement_api.php")
data_dir = root / ".achievement_data"
data_dir.mkdir()
state_path = data_dir / "state.json"
server = PhpServer(root)
try:
original, compact = run_migration_cases(server, state_path)
run_corruption_cases(server, state_path)
run_completionist_case(server, state_path)
run_concurrency_case(server, state_path)
finally:
server.close()
print(
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
f"completionist filtering, GET mutation rejection, and 75 concurrent syncs passed; "
f"aggregate JSON shrank {original}{compact} bytes ({compact / original:.1%})"
)
if __name__ == "__main__":
main()

View file

@ -1,124 +0,0 @@
param(
[string]$Query = "",
[switch]$All,
[switch]$Json
)
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
$RoutingPath = Join-Path $Root "FILES.json"
$Routing = Get-Content -Raw -LiteralPath $RoutingPath | ConvertFrom-Json
$Exact = @{
".gitignore" = "ignore rules for local/generated artifacts"
".htaccess" = "Apache/static-host cache, MIME, and service-worker behavior"
"app_manifest.json" = "canonical version plus CSS/JS app shell manifest"
"favicon.ico" = "legacy favicon"
"index.html" = "app DOM shell, canvas, panels, dialogs, and ordered script tags"
"README.md" = "small AI entrypoint and read strategy"
"AI_MAP.md" = "subsystem map and task-oriented read sets"
"GAME_FEATURES.md" = "player-facing game features, UI, items, and behavior guide"
"FILES.json" = "machine-readable file routing and glob semantics"
"service-worker.js" = "generated offline/cache service worker"
}
$JsExact = @{
"ants.js" = "ant actor and ant nest worker behavior"
"assets.js" = "image loading, image metrics, and render caches"
"audio.js" = "sample playback and audio controls"
"burn_motion_util.js" = "burning drift and smoke motion helpers"
"collision_footprint_system.js" = "collision footprint shapes and overlap tests"
"command_dispatcher.js" = "central command routing for UI/tools/pointers/save"
"constraint_system.js" = "attachments, ropes, push/pull, and collision constraints"
"data.js" = "sprite/decor definitions, needs, config, field tuning"
"debug_tools.js" = "browser debug overlay and diagnostics"
"disease_registry.js" = "disease definitions and lookup"
"display_helpers.js" = "shared canvas/display helpers and weather labels"
"event_bus.js" = "global event bus singleton"
"family_graph.js" = "lineage graph helpers"
"geometry_helpers.js" = "shared primitive geometry helpers"
"ground_types.js" = "ground definitions, multipliers, limits, labels"
"health.js" = "health, stress, need, damage, and death helpers"
"history_system.js" = "rolling world statistics/history"
"impact_core_system.js" = "impact force and hit candidate helpers"
"input_mode_manager.js" = "pointer/touch/mobile input classification"
"items.js" = "item constructors, food/grass helpers, item preview drawing"
"main.js" = "startup, restore, UI binding, update/render loop"
"math.js" = "shared numeric helpers"
"mechanical_shape_bridge.js" = "mechanical item shape bridge for world queries"
"mechanical_system.js" = "mechanical item forces, rails, ropes, pins, fences"
"perf_profiler.js" = "performance profiler and quality adaptation"
"registry_base.js" = "registry normalization helper base"
"render.js" = "main canvas renderer"
"restore_coordinator.js" = "post-load restore ordering/fixups"
"sim_core.js" = "simulation primitives and shared helpers"
"snapshot_system.js" = "world snapshot capture/restore"
"sound_pack.js" = "logical sound ID to asset mapping"
"structure_lifecycle.js" = "structure update lifecycle"
"structures.js" = "structure definitions and placement/runtime helpers"
"system_order.js" = "named update phase order"
"text_catalog.js" = "Japanese UI/state/behavior labels"
"version.js" = "app version/cache/static query config"
"weather_system.js" = "weather selection and application"
}
function Get-GroupId($Path) {
foreach ($Group in $Routing.groups) {
foreach ($Pattern in $Group.patterns) {
if ($Path -like $Pattern) { return $Group.id }
}
}
return ($Path -split "/")[0]
}
function Get-Meaning($Path) {
if ($Exact.ContainsKey($Path)) { return $Exact[$Path] }
if ($Path.StartsWith("js/")) {
$Name = Split-Path -Leaf $Path
if ($JsExact.ContainsKey($Name)) { return $JsExact[$Name] }
if ($Name.StartsWith("world_")) { return "world state/view/update/tool/environment/combat/social subsystem" }
if ($Name.StartsWith("tarinai_")) { return "creature action/needs/social/item/update subsystem" }
if ($Name.StartsWith("item_lifecycle_step_")) { return "item lifecycle pipeline stage" }
if ($Name.StartsWith("item_lifecycle_")) { return "item lifecycle subsystem" }
if ($Name.StartsWith("item_dynamic_")) { return "dynamic item behavior subsystem" }
if ($Name.StartsWith("item_")) { return "item registry/runtime/update/render subsystem" }
if ($Name.StartsWith("simulation_")) { return "simulation phase subsystem" }
if ($Name.StartsWith("ui_family_")) { return "family tree UI subsystem" }
if ($Name.StartsWith("ui_input_")) { return "input handling subsystem" }
if ($Name.StartsWith("ui_")) { return "DOM/panel UI subsystem" }
if ($Name.StartsWith("save_")) { return "save/load persistence subsystem" }
if ($Name.StartsWith("physics_")) { return "physics helper/world subsystem" }
return "runtime JavaScript module"
}
if ($Path.StartsWith("scripts/")) { return "repo script/tooling" }
if ($Path.StartsWith("css/")) { return "stylesheet layer" }
if ($Path.StartsWith("assets/sprites/")) { return "creature state sprite" }
if ($Path.StartsWith("assets/ui/")) { return "UI/app/tool image asset" }
if ($Path.StartsWith("assets/objects/")) { return "field object image asset" }
if ($Path.StartsWith("assets/sounds/")) { return "voice or sound-effect sample" }
return "project file"
}
$Files = Get-ChildItem -LiteralPath $Root -Recurse -File -Force |
Where-Object { $_.FullName -notmatch "\\.git\\" -and $_.FullName -notmatch "\\__pycache__\\" } |
ForEach-Object { $_.FullName.Substring($Root.Path.Length + 1).Replace("\", "/") } |
Sort-Object
$Records = foreach ($Path in $Files) {
$Group = Get-GroupId $Path
$Meaning = Get-Meaning $Path
if ($Query -and ($Path.ToLowerInvariant() -notlike "*$($Query.ToLowerInvariant())*" -and $Group.ToLowerInvariant() -notlike "*$($Query.ToLowerInvariant())*" -and $Meaning.ToLowerInvariant() -notlike "*$($Query.ToLowerInvariant())*")) {
continue
}
[pscustomobject]@{ path = $Path; group = $Group; meaning = $Meaning }
}
if ($Json) {
$Records | ConvertTo-Json -Depth 4
} elseif ($All) {
$Records | ForEach-Object { "$($_.path): $($_.meaning)" }
} else {
$Records | Group-Object group | Sort-Object Name | ForEach-Object {
"$($_.Name): $($_.Count) files"
$_.Group | Select-Object -ExpandProperty meaning -Unique | Sort-Object | Select-Object -First 8 | ForEach-Object { " - $_" }
}
}

View file

@ -1,212 +0,0 @@
from __future__ import annotations
import argparse
import fnmatch
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ROUTING_FILE = ROOT / "FILES.json"
EXCLUDE_DIRS = {".git", "__pycache__"}
EXACT_DESCRIPTIONS = {
".gitignore": "ignore rules for local/generated artifacts",
".htaccess": "Apache/static-host cache, MIME, and service-worker behavior",
"app_manifest.json": "canonical version plus CSS/JS app shell manifest",
"favicon.ico": "legacy favicon",
"index.html": "app DOM shell, canvas, panels, dialogs, and ordered script tags",
"README.md": "small AI entrypoint and read strategy",
"AI_MAP.md": "subsystem map and task-oriented read sets",
"GAME_FEATURES.md": "player-facing game features, UI, items, and behavior guide",
"FILES.json": "machine-readable file routing and glob semantics",
"service-worker.js": "generated offline/cache service worker",
}
GROUP_DESCRIPTIONS = {
"css": "stylesheet layer",
"scripts": "repo tooling",
"js": "runtime JavaScript",
"assets/sprites": "creature state sprite",
"assets/ui": "UI/app/tool image asset",
"assets/objects": "field object image asset",
"assets/sounds": "voice or sound-effect sample",
}
JS_PREFIX_DESCRIPTIONS = [
("world_", "world state/view/update/tool/environment/combat/social subsystem"),
("tarinai_", "creature action/needs/social/item/update subsystem"),
("item_lifecycle_step_", "item lifecycle pipeline stage"),
("item_lifecycle_", "item lifecycle subsystem"),
("item_dynamic_", "dynamic item behavior subsystem"),
("item_", "item registry/runtime/update/render subsystem"),
("simulation_", "simulation phase subsystem"),
("ui_family_", "family tree UI subsystem"),
("ui_input_", "input handling subsystem"),
("ui_", "DOM/panel UI subsystem"),
("save_", "save/load persistence subsystem"),
("physics_", "physics helper/world subsystem"),
]
JS_EXACT_DESCRIPTIONS = {
"ants.js": "ant actor and ant nest worker behavior",
"assets.js": "image loading, image metrics, and render caches",
"audio.js": "sample playback and audio controls",
"burn_motion_util.js": "burning drift and smoke motion helpers",
"collision_footprint_system.js": "collision footprint shapes and overlap tests",
"command_dispatcher.js": "central command routing for UI/tools/pointers/save",
"constraint_system.js": "attachments, ropes, push/pull, and collision constraints",
"data.js": "sprite/decor definitions, needs, config, field tuning",
"display_helpers.js": "shared canvas/display helpers and weather labels",
"debug_tools.js": "browser debug overlay and diagnostics",
"disease_registry.js": "disease definitions and lookup",
"event_bus.js": "global event bus singleton",
"family_graph.js": "lineage graph helpers",
"geometry_helpers.js": "shared primitive geometry helpers",
"ground_types.js": "ground definitions, multipliers, limits, labels",
"health.js": "health, stress, need, damage, and death helpers",
"history_system.js": "rolling world statistics/history",
"impact_core_system.js": "impact force and hit candidate helpers",
"input_mode_manager.js": "pointer/touch/mobile input classification",
"items.js": "item constructors, food/grass helpers, item preview drawing",
"main.js": "startup, restore, UI binding, update/render loop",
"math.js": "shared numeric helpers",
"mechanical_shape_bridge.js": "mechanical item shape bridge for world queries",
"mechanical_system.js": "mechanical item forces, rails, ropes, pins, fences",
"perf_profiler.js": "performance profiler and quality adaptation",
"registry_base.js": "registry normalization helper base",
"render.js": "main canvas renderer",
"restore_coordinator.js": "post-load restore ordering/fixups",
"sim_core.js": "simulation primitives and shared helpers",
"sound_pack.js": "logical sound ID to asset mapping",
"snapshot_system.js": "world snapshot capture/restore",
"structures.js": "structure definitions and placement/runtime helpers",
"structure_lifecycle.js": "structure update lifecycle",
"system_order.js": "named update phase order",
"text_catalog.js": "Japanese UI/state/behavior labels",
"version.js": "app version/cache/static query config",
"weather_system.js": "weather selection and application",
}
def rel(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def iter_files() -> list[str]:
files: list[str] = []
for path in ROOT.rglob("*"):
if not path.is_file():
continue
if any(part in EXCLUDE_DIRS for part in path.relative_to(ROOT).parts):
continue
files.append(rel(path))
return sorted(files)
def load_routing() -> dict:
return json.loads(ROUTING_FILE.read_text(encoding="utf-8"))
def group_for(path: str, routing: dict) -> str:
for group in routing.get("groups", []):
for pattern in group.get("patterns", []):
if fnmatch.fnmatch(path, pattern):
return group["id"]
return path.split("/", 1)[0]
def describe(path: str, routing: dict) -> str:
if path in EXACT_DESCRIPTIONS:
return EXACT_DESCRIPTIONS[path]
if path.startswith("js/"):
name = path.rsplit("/", 1)[-1]
if name in JS_EXACT_DESCRIPTIONS:
return JS_EXACT_DESCRIPTIONS[name]
for prefix, desc in JS_PREFIX_DESCRIPTIONS:
if name.startswith(prefix):
return desc
return "runtime JavaScript module"
if path.startswith("scripts/"):
return "repo script/tooling"
if path.startswith("css/"):
return "stylesheet layer"
if path.startswith("assets/"):
for prefix, desc in GROUP_DESCRIPTIONS.items():
if path.startswith(prefix + "/"):
return desc
return "static asset"
group_id = group_for(path, routing)
for group in routing.get("groups", []):
if group.get("id") == group_id:
return group.get("meaning", "project file")
return "project file"
def matches_query(path: str, query: str, routing: dict) -> bool:
if not query:
return True
q = query.lower()
if q in path.lower():
return True
group_id = group_for(path, routing).lower()
if q == group_id or q in group_id:
return True
return q in describe(path, routing).lower()
def build_records(query: str = "") -> list[dict]:
routing = load_routing()
records = []
for path in iter_files():
if not matches_query(path, query, routing):
continue
records.append({
"path": path,
"group": group_for(path, routing),
"meaning": describe(path, routing),
})
return records
def print_compact(records: list[dict]) -> None:
groups: dict[str, list[dict]] = {}
for record in records:
groups.setdefault(record["group"], []).append(record)
for group_id in sorted(groups):
print(f"{group_id}: {len(groups[group_id])} files")
meanings = sorted({record["meaning"] for record in groups[group_id]})
for meaning in meanings[:8]:
print(f" - {meaning}")
def print_all(records: list[dict]) -> None:
for record in records:
print(f"{record['path']}: {record['meaning']}")
def main() -> int:
parser = argparse.ArgumentParser(description="Print token-aware AI file inventory.")
parser.add_argument("query", nargs="?", default="", help="Optional group/path/meaning filter, e.g. ui, world, assets.")
parser.add_argument("--all", action="store_true", help="Print every matching file instead of grouped summary.")
parser.add_argument("--json", action="store_true", help="Print JSON records.")
args = parser.parse_args()
records = build_records(args.query)
if args.json:
print(json.dumps(records, ensure_ascii=False, indent=2))
elif args.all:
print_all(records)
else:
print_compact(records)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,44 +0,0 @@
"use strict";
global.window = global;
global.CONFIG = { dayLength: 120 };
global.TarinaiAchievements = { evaluateWorld() {} };
require("../js/colony_situation_system.js");
function actor({ energy = 80, hunger = 20, health = 0 } = {}) {
return { dead: false, energy, hunger, stress: 10, needs: { health, safety: 0 }, state: "idle" };
}
function world(actors, deaths = 0, previousPopulation = null) {
return {
time: 100,
tarinai: actors,
recentDeathTimes: Array.from({ length: deaths }, (_, i) => 99 - i * 0.1),
lastColonyMoodPopulation: previousPopulation == null ? actors.length : previousPopulation,
colonyMoodDefinition(id) { return { id, label: id, effects: { personality: {} } }; },
config: { dayLength: 120 },
};
}
function evaluate(w) { return global.TarinaiColonySituationSystem.evaluate(w, true).id; }
function assert(ok, message) { if (!ok) throw new Error(message); }
// Ten recent deaths alone at 100 population must no longer trigger crisis without systemic weakness.
let w = world(Array.from({ length: 100 }, () => actor()), 10);
assert(evaluate(w) !== "crisis", "recent deaths alone still trigger crisis too easily");
// Systemic weakness alone below collapse severity must not trigger crisis.
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 0);
assert(evaluate(w) !== "crisis", "moderate systemic weakness alone still triggers crisis");
// Mortality plus systemic weakness should trigger crisis.
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 10);
assert(evaluate(w) === "crisis", "compound mortality/systemic stress does not trigger crisis");
// Extreme collapse can trigger even without recent deaths.
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 0);
assert(evaluate(w) === "crisis", "extreme colony collapse does not trigger crisis");
// Population growth still suppresses crisis.
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 20, 90);
assert(evaluate(w) !== "crisis", "crisis ignored population-growth exclusion");
console.log("[OK] stricter compound crisis classification passed");

View file

@ -1,18 +0,0 @@
"use strict";
const fs = require("fs");
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
const sim = fs.readFileSync("js/simulation_effects_system.js", "utf8");
const combat = fs.readFileSync("js/world_combat_effects.js", "utf8");
const all = fs.readdirSync("js").filter(x => x.endsWith(".js")).map(x => fs.readFileSync(`js/${x}`, "utf8")).join("\n");
ok(!/\.map\([\s\S]{0,300}\.sort\(/.test(sim), "effect pressure compaction no longer map/sorts all effects");
ok(sim.includes("for (let priority = 1; priority <= 3"), "effect pressure drops by priority tiers");
ok(combat.includes('"bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"'), "all transient effect variants use the reusable pool path");
ok(combat.includes("offscreenCulled"), "low-importance offscreen spawns are tracked and culled");
ok(combat.includes('visualLevel?.("effects", "high") === "off"'), "effect creation is gated by the visual-effects setting");
ok(sim.includes('visualLevel?.("effects", "high") === "off"') && sim.includes("clearDisabledEffects"), "disabled effects are released instead of updated");
ok(sim.includes('if (effect.type === "ring")') && sim.includes("lightweight"), "rings bypass the budgeted physics update path");
ok(!sim.includes("const budgeted = []"), "effect updates do not allocate a temporary active-effect array each frame");
ok(sim.includes("spawnRequested") && sim.includes("poolEligible") && sim.includes("pooled"), "effect runtime diagnostics expose spawn and pool counters");
const direct = [...all.matchAll(/(?:effects(?:\?\.)?\.push|effects\.push)\(new\s+(?:global\.)?(?:TarinaiEffect|EffectCtor|Effect)\(/g)].length;
ok(direct === 0, "normal Effect construction is routed through queueEffect");
console.log("[OK] effect runtime regression audit passed");

View file

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Regenerate index.html CSS/script tags and service-worker.js from app_manifest.json.
This project intentionally stays buildless; this script is a lightweight manifest sync tool.
"""
from __future__ import annotations
from pathlib import Path
import json
import re
ROOT = Path(__file__).resolve().parents[1]
manifest = json.loads((ROOT / "app_manifest.json").read_text(encoding="utf-8"))
version = manifest["version"]
css_files = manifest["css"]
js_files = manifest["js"]
index = ROOT / "index.html"
s = index.read_text(encoding="utf-8")
css_block = "".join([f'\n <link rel="stylesheet" href="{f}?v={version}" />' for f in css_files])
s = re.sub(
r'\s*<link rel="stylesheet" href="css/[^\"]+" />(?:\s*<link rel="stylesheet" href="css/[^\"]+" />)*',
css_block,
s,
count=1,
)
script_block = "\n".join([f' <script src="{f}?v={version}" defer></script>' for f in js_files])
static_icon_paths = ["assets/ui/favicon.png", "assets/ui/apple-touch-icon.png", "favicon.ico"]
for icon_path in static_icon_paths:
s = re.sub(rf'(href="{re.escape(icon_path)}\?v=)[^"]+', rf'\g<1>{version}', s)
s = re.sub(
r'\n\s*<script src="js/version\.js\?v=[^\"]+" defer></script>[\s\S]*?\n\s*<script src="js/debug_tools\.js\?v=[^\"]+" defer></script>',
"\n" + script_block,
s,
count=1,
)
index.write_text(s, encoding="utf-8")
core_names = [Path(f).stem for f in js_files]
css_entries = ",\n ".join([f'`./{f}?${{v}}`' for f in css_files])
js_names = ", ".join([json.dumps(n, ensure_ascii=False) for n in core_names])
(ROOT / "service-worker.js").write_text(f'''"use strict";\n\nconst APP_VERSION = "{version}";\nconst CACHE_NAME = `tarinai-colony-${{APP_VERSION}}`;\nconst v = `v=${{APP_VERSION}}`;\n// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.\nconst coreScriptNames = [\n {js_names}\n];\nconst CORE_ASSETS = [\n "./",\n "./index.html",\n {css_entries},\n ...coreScriptNames.map(name => `./js/${{name}}.js?${{v}}`),\n];\n\nself.addEventListener("install", (event) => {{\n event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined));\n self.skipWaiting();\n}});\n\nself.addEventListener("activate", (event) => {{\n event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));\n self.clients.claim();\n}});\n\nfunction isStaticAsset(request) {{\n const url = new URL(request.url);\n return /\\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname);\n}}\n\nfunction cacheableResponse(response) {{\n return response && response.status === 200 && response.type !== "opaque";\n}}\n\nfunction putCacheSafely(request, response) {{\n if (!cacheableResponse(response)) return Promise.resolve(false);\n return caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())).then(() => true).catch(() => false);\n}}\n\nself.addEventListener("fetch", (event) => {{\n const request = event.request;\n if (request.method !== "GET") return;\n const url = new URL(request.url);\n if (url.origin !== self.location.origin) return;\n if (/service-worker\\.js$/i.test(url.pathname)) return;\n if (request.mode === "navigate" || /index\\.html$/i.test(url.pathname)) {{\n event.respondWith(fetch(request).then(response => {{\n putCacheSafely(request, response);\n return response;\n }}).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));\n return;\n }}\n if (isStaticAsset(request)) {{\n event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {{\n putCacheSafely(request, response);\n return response;\n }})));\n }}\n}});\n''', encoding="utf-8")
# Keep machine routing metadata version-aligned when present.
files_json = ROOT / "FILES.json"
if files_json.exists():
try:
files_data = json.loads(files_json.read_text(encoding="utf-8"))
if isinstance(files_data, dict):
files_data["version"] = version
files_json.write_text(json.dumps(files_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
except Exception:
pass

View file

@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""Generate UI icons for item designs that must match js/items.js.
The game is buildless, but these icons are generated snapshots of the field
appearance. Do not invent separate tool-UI-only designs.
"""
from __future__ import annotations
from pathlib import Path
import json, math, re
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "assets" / "ui"
REGISTRY = ROOT / "js" / "item_registry.js"
def visual_registry() -> dict:
text = REGISTRY.read_text(encoding="utf-8")
match = re.search(r"String\.raw`([\s\S]*?)`", text)
if not match:
raise RuntimeError("Could not find JSON visual registry in js/item_registry.js")
return json.loads(match.group(1))
def save_icon(name: str, draw_fn) -> None:
im = Image.new("RGBA", (96, 96), (0, 0, 0, 0))
d = ImageDraw.Draw(im, "RGBA")
draw_fn(d)
im.save(OUT / name, "WEBP", lossless=True, quality=95)
def draw_drop(fill, stroke, highlight):
def fn(d: ImageDraw.ImageDraw) -> None:
d.ellipse([18, 60, 78, 74], fill=(60, 45, 35, 32))
# Same horizontal oval as fallen water/zunda/mercury item sprites; rain overlay is separate diagonal lines.
d.ellipse([12, 33, 84, 63], fill=fill, outline=stroke, width=4)
d.ellipse([24, 38, 45, 48], fill=highlight)
return fn
def rgba(value, fallback):
if not value:
return fallback
if value.startswith("#"):
value = value.lstrip("#")
if len(value) == 6:
return tuple(int(value[i:i+2], 16) for i in (0, 2, 4)) + (255,)
match = re.match(r"rgba?\(([^)]+)\)", value.replace(" ", ""))
if match:
parts = match.group(1).split(",")
r, g, b = (int(float(parts[i])) for i in range(3))
a = int(float(parts[3]) * 255) if len(parts) > 3 else 255
return (r, g, b, a)
return fallback
def draw_powder(fill, stroke, highlight):
def fn(d: ImageDraw.ImageDraw) -> None:
d.ellipse([18, 62, 78, 76], fill=(60, 45, 35, 36))
for i in range(20):
x = 48 + math.cos(i * 1.9) * (8 + (i % 5) * 4)
y = 56 - abs(math.sin(i * 1.3)) * 24 + (i % 3) * 3
r = 5 + (i % 4)
d.ellipse([x - r, y - r * 0.65, x + r, y + r * 0.65], fill=highlight if i % 4 == 0 else fill, outline=stroke, width=1)
d.ellipse([31, 31, 52, 39], fill=rgba("rgba(255,255,255,0.62)", (255, 255, 255, 158)))
return fn
def draw_pill(fill, stroke, highlight, scale=1.0):
def fn(d: ImageDraw.ImageDraw) -> None:
cx, cy = 48, 48
w, h = 66 * scale, 28 * scale
box = [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]
d.rounded_rectangle(box, radius=int(h / 2), fill=fill, outline=stroke, width=4)
d.line([(cx, cy - h / 2 + 2), (cx, cy + h / 2 - 2)], fill=highlight, width=4)
d.ellipse([cx - w * 0.32, cy - h * 0.24, cx - w * 0.08, cy - h * 0.04], fill=(255, 255, 255, 110))
return fn
def draw_laxative(d: ImageDraw.ImageDraw) -> None:
d.rounded_rectangle([14, 28, 82, 68], radius=15, fill=(244, 212, 154, 255), outline=(98, 61, 29, 235), width=4)
# Zunchi mound motif inside the tablet.
for box in ([26, 47, 44, 60], [38, 36, 60, 53], [54, 47, 74, 61], [46, 29, 61, 41]):
d.ellipse(box, fill=(96, 70, 38, 230), outline=(56, 39, 22, 130), width=2)
d.arc([22, 27, 72, 50], 205, 330, fill=(255, 255, 255, 135), width=3)
def star_points(cx: float, cy: float, r1: float, r2: float, n: int = 5, rot: float = -math.pi / 2):
return [(cx + math.cos(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2), cy + math.sin(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2)) for i in range(n * 2)]
def draw_mystery(d: ImageDraw.ImageDraw) -> None:
d.ellipse([18, 23, 78, 73], fill=(243, 236, 255, 255), outline=(91, 55, 150, 240), width=5)
try:
from PIL import ImageFont
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 44)
except Exception:
font = None
d.text((48, 48), "?", anchor="mm", fill=(91, 55, 150, 245), font=font)
d.arc([25, 24, 74, 54], 205, 330, fill=(255, 255, 255, 135), width=3)
def draw_bullet(d: ImageDraw.ImageDraw) -> None:
d.ellipse([20, 62, 76, 77], fill=(60, 45, 35, 38))
pts = [(76, 48), (60, 28), (27, 28), (16, 35), (16, 61), (27, 68), (60, 68)]
d.polygon(pts, fill=(244, 196, 106, 255), outline=(87, 51, 22, 240))
d.line(pts + [pts[0]], fill=(87, 51, 22, 240), width=4)
d.rectangle([16, 35, 29, 61], fill=(176, 111, 35, 255), outline=(87, 51, 22, 220), width=3)
d.line([(34, 36), (64, 42)], fill=(255, 237, 176, 170), width=4)
d.line([(34, 60), (58, 56)], fill=(142, 82, 30, 110), width=3)
def draw_sleep(d: ImageDraw.ImageDraw) -> None:
d.ellipse([17, 28, 79, 68], fill=(235, 228, 255, 255), outline=(74, 60, 144, 230), width=4)
d.ellipse([41, 31, 65, 56], fill=(112, 91, 188, 220))
d.ellipse([52, 27, 74, 52], fill=(235, 228, 255, 255))
d.arc([24, 27, 74, 57], 200, 330, fill=(255, 255, 255, 190), width=3)
def main() -> None:
registry = visual_registry()
renderer_map = {
"laxative_tablet": draw_laxative,
"mystery_tablet": draw_mystery,
"bullet": draw_bullet,
"sleep_tablet": draw_sleep,
}
for item_id, visual in registry.items():
if item_id == "ball":
continue
palette = visual.get("palette") or {}
renderer = visual.get("renderer")
fill = rgba(palette.get("fill"), (255, 250, 241, 255))
stroke = rgba(palette.get("stroke"), (141, 111, 72, 220))
highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150))
if renderer == "oval_droplet":
draw_fn = draw_drop(fill, stroke, highlight)
elif renderer == "powder_pile":
draw_fn = draw_powder(fill, stroke, highlight)
elif renderer == "split_pill":
draw_fn = draw_pill(fill, stroke, highlight, visual.get("uiPreviewScale") or visual.get("scale") or 1.0)
else:
draw_fn = renderer_map.get(renderer)
if draw_fn:
save_icon(f"tool_{item_id}.webp", draw_fn)
if __name__ == "__main__":
main()

View file

@ -1,46 +0,0 @@
"use strict";
const path = require("path");
global.window = global;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
global.TarinaiSaveCodec = {
encodeBinarySnapshot(snapshot) {
return encoder.encode(JSON.stringify(snapshot));
},
decodeBinarySnapshot(bytes) {
return JSON.parse(decoder.decode(bytes));
},
};
global.TarinaiSnapshot = {
createSnapshot(world) {
return { v: 1, a: "tj1", value: world.value, time: world.time };
},
};
global.TarinaiRestoreCoordinator = {
restoreSnapshot(snapshot, world) {
world.value = snapshot.value;
world.time = snapshot.time;
},
};
require(path.join(__dirname, "..", "js", "history_system.js"));
const world = { value: 1, time: 10 };
if (!global.TarinaiHistory.capture(world, "first")) throw new Error("initial history capture failed");
if (!(world._undoStack[0].bytes instanceof Uint8Array)) throw new Error("history entry is not binary");
if ("snapshot" in world._undoStack[0] || "patch" in world._undoStack[0]) throw new Error("legacy object history payload remains");
if (global.TarinaiHistory.capture(world, "duplicate")) throw new Error("duplicate snapshot was not suppressed");
world.value = 2;
world.time = 20;
if (!global.TarinaiHistory.capture(world, "second")) throw new Error("second history capture failed");
world.value = 3;
world.time = 30;
let result = global.TarinaiHistory.undo(world);
if (!result.ok || world.value !== 2 || world.time !== 20) throw new Error("undo did not restore the binary snapshot");
result = global.TarinaiHistory.redo(world);
if (!result.ok || world.value !== 3 || world.time !== 30) throw new Error("redo did not restore the captured current state");
console.log("[OK] binary undo/redo history, duplicate suppression, and synchronous restore passed");

View file

@ -1,84 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const ROOT = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
// Long-session transient caches must expire without relying on Tarinai deaths.
{
const context = { console, Map, Set, Object, Number, Math, Array, CONFIG: { dayLength: 120 } };
context.window = context;
context.globalThis = context;
vm.createContext(context);
vm.runInContext(read("js/simulation_maintenance_system.js"), context);
const phases = context.TarinaiMaintenanceSimulationSystem.phases;
let familyPrunes = 0;
const world = {
time: 100, compactTimer: 0, drawSortTimer: 0,
_maintenanceScheduleReady: true,
_nextStructureDependencyCheckAt: 999, _nextItemCompactAt: 999, _nextAntCompactAt: 999,
_nextEffectCountRefreshAt: 999, _nextTransientRuntimePruneAt: 0,
nextGrassLimitCheckAt: 999, nextFireCompactAt: 999, nextGrassBedExpiryCheckAt: 999, outOfBoundsCheckAt: 100,
itemCounts: {}, items: [], effects: [], itemBucketsDirty: false, familyPrunePending: true,
ballCollisionMemo: new Map([["old", 90], ["new", 99.5]]),
tarinaiCollisionMemo: new Map([["old", 90], ["new", 99.2]]),
relationNotices: { old: -30, recent: 10 },
fightPairCooldowns: { old: 99, active: 110 },
resolvedFightIds: { old: 90, recent: 99 },
pruneExtinctFamilies() { familyPrunes += 1; },
compactTarinai() {}, compactItems() { return false; }, compactAnts() {},
updateItemCounts() {}, updateEffectCounts() {}, sanitizeOutOfBounds() {},
};
phases.runMaintenance(world, 0.01, 0);
assert(!world.ballCollisionMemo.has("old") && world.ballCollisionMemo.has("new"));
assert(!world.tarinaiCollisionMemo.has("old") && world.tarinaiCollisionMemo.has("new"));
assert(!Object.prototype.hasOwnProperty.call(world.relationNotices, "old") && Object.prototype.hasOwnProperty.call(world.relationNotices, "recent"));
assert(!Object.prototype.hasOwnProperty.call(world.fightPairCooldowns, "old") && Object.prototype.hasOwnProperty.call(world.fightPairCooldowns, "active"));
assert(!Object.prototype.hasOwnProperty.call(world.resolvedFightIds, "old") && Object.prototype.hasOwnProperty.call(world.resolvedFightIds, "recent"));
assert.strictEqual(familyPrunes, 0, "family graph pruning must stay dormant outside the family view");
assert.strictEqual(world.familyPrunePending, true, "pending family cleanup should be preserved for the family view");
console.log("[OK] transient runtime caches expire independently of deaths");
console.log("[OK] hidden family graph is not swept by simulation maintenance");
}
// Effects-off must release the live queue without running Effect.update.
{
let updates = 0;
let releases = 0;
const context = { console, Math, Number, Array, Uint8Array };
context.window = context;
context.globalThis = context;
context.TarinaiPerf = {
visualLevel() { return "off"; },
performanceProfile() { return { effectCap: 1e9, effectUpdateBudget: 1e9 }; },
diagnosticsEnabled() { return false; },
};
vm.createContext(context);
vm.runInContext(read("js/simulation_effects_system.js"), context);
const world = {
effects: [
{ dead: false, type: "flame", update() { updates += 1; } },
{ dead: false, type: "ring", update() { updates += 1; } },
],
releaseEffect() { releases += 1; },
};
context.TarinaiEffectsSimulationSystem.phases.updateEffects(world, 1 / 60);
assert.strictEqual(updates, 0);
assert.strictEqual(releases, 2);
assert.strictEqual(world.effects.length, 0);
console.log("[OK] effects-off performs no transient Effect updates and clears the queue");
}
// Fight-resolution dedupe must be pair-timestamp based rather than an ever-growing time bucket key.
{
const combat = read("js/world_combat_effects.js");
assert(combat.includes('const key = `${winner.id}:${loser.id}`;'));
assert(!combat.includes('Math.floor(this.time / 2)'));
assert(combat.includes('this.resolvedFightIds[key] = this.time;'));
console.log("[OK] repeated fight outcomes no longer create permanent time-bucket keys");
}
console.log("Long-session runtime regression audit passed.");

View file

@ -1,21 +0,0 @@
"use strict";
const fs = require("fs");
const viewSrc = fs.readFileSync("js/world_view.js", "utf8");
const maintenanceSrc = fs.readFileSync("js/simulation_maintenance_system.js", "utf8");
const spatialSrc = fs.readFileSync("js/world_spatial_budget.js", "utf8");
const mainSrc = fs.readFileSync("js/main.js", "utf8");
const statsSrc = fs.readFileSync("js/ui_charts.js", "utf8");
const decaySrc = fs.readFileSync("js/item_lifecycle_decay_system.js", "utf8");
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
ok(!viewSrc.includes('terrain-periodic'), "terrain cache is not globally invalidated on a timer");
ok(viewSrc.includes('return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}`'), "terrain signature excludes object-count churn");
ok(maintenanceSrc.includes('_nextStructureDependencyCheckAt'), "structure dependency safety sweeps are throttled");
ok(maintenanceSrc.includes('_nextItemCompactAt') && maintenanceSrc.includes('_nextAntCompactAt'), "item and ant compactions are staggered");
ok(!maintenanceSrc.includes('countsInterval = 2.75'), "item bucket rebuilds are no longer forced by a periodic timer");
ok(spatialSrc.includes('if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items")'), "item compaction only invalidates terrain when cached terrain items were removed");
ok(!spatialSrc.includes('else this.markTerrainDirty?.(reason);'), "adding ordinary objects no longer invalidates the whole terrain cache");
ok(mainSrc.includes('requestIdleCallback') && mainSrc.includes('scheduleStatsRefresh'), "periodic stats refresh is deferred outside the simulation frame");
ok(statsSrc.includes('world.tarinaiCounts?.()') && statsSrc.includes('collectDynamicColonyStats'), "stats use differential population counters and isolate dynamic aggregation");
ok(statsSrc.includes('colonyStatsUiVisible') && mainSrc.includes('!colonyStatsUiVisible()'), "hidden colony UI skips scheduled stats work");
ok(decaySrc.includes('"splat-fade"'), "splat fading invalidates only its local terrain chunk");
if (process.exitCode) process.exit(process.exitCode);

View file

@ -1,25 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(root, rel), "utf8");
let failed = false;
function ok(name, cond) {
if (cond) console.log(`[OK] ${name}`);
else { console.error(`[FAIL] ${name}`); failed = true; }
}
const render = read("js/render.js");
const sim = read("js/simulation_creature_system.js");
const frame = read("js/tarinai_update_step_frame.js");
const care = read("js/tarinai_cursor_care_system.js");
ok("cursor care is updated from one spatial query per frame", sim.includes("function updateCursorCare") && sim.includes("worldRef.nearbyTarinai?.") && sim.includes("updateCursorCare(worldRef, dt)"));
ok("cursor care is no longer cadence-throttled inside full Tarinai updates", !frame.includes("TarinaiCursorCareSystem.updateOne"));
ok("cursor hearts use the central effect queue", care.includes('this.world.queueEffect("heart"') && !care.includes("new EffectCtor"));
ok("stable object raster cache exists", render.includes("STATIC_ITEM_BITMAP_TYPES") && render.includes("drawStaticItemBitmapCached"));
ok("cached objects preserve individual depth-stack entries", render.includes("drawStaticItemBitmapCached(ctx, it, world.time, lighting, world)") && render.includes("drawRenderEntity(ctx, entry.entity, world.time, lighting, world)"));
ok("close-up object art bypasses raster cache", render.includes("viewScale?.() || 1) > 1.35"));
ok("slow-frame visible-creature anti-spiral budget is bounded", sim.includes("antiSpiralVisibleBudget") && sim.includes("Math.floor(2.8 / Math.max(0.028, dt))"));
ok("budget-deferred visible creatures keep motion-only updates", sim.includes("updateMotionOnly(t, dt, { context })"));
if (failed) process.exit(1);
console.log("Object render/cursor regression audit passed.");

View file

@ -1,16 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = p => fs.readFileSync(path.join(root, p), "utf8");
const creature = read("js/simulation_creature_system.js");
const movement = read("js/tarinai_update_step_movement.js");
const social = read("js/tarinai_social_move_life.js");
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
ok(/offscreenCatchup: true/.test(creature) && /motionDt: Math\.min\(runDt, 2\.4\)/.test(creature), "off-screen scheduled updates catch up the full accumulated movement time");
ok(/context\.offscreenCatchup \? 2\.4 : 0\.05/.test(movement), "off-screen catch-up is allowed beyond one rendered-frame dt");
ok(/context\.motionOnly \|\| context\.offscreenCatchup/.test(movement), "off-screen catch-up uses the light collision path");
ok(/const candidateBudget = Math\.max\(20, scanLimit \+ 8\)/.test(social), "dense social contact work has a fixed candidate budget");
ok(/if \(inspected >= candidateBudget\) break;/.test(social), "contact iteration stops at the candidate budget");
ok(!/const mateDetailed = \[\]/.test(social), "full local-population mate reserve scan is removed");
console.log("Off-screen motion/performance regression audit passed.");

View file

@ -1,58 +0,0 @@
"use strict";
const path = require("path");
global.window = global;
global.CONFIG = { worldPadding: 28 };
global.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
class World {}
global.World = World;
require(path.join(__dirname, "..", "js", "world_pathfinding_system.js"));
function makeWorld({ directBlocked = false } = {}) {
const w = new World();
w.w = 1400;
w.h = 1000;
w.time = 10;
w.routingObstacleVersion = 3;
w.pointChecks = 0;
w.edgeChecks = 0;
w.pointBlockedByObstacle = () => { w.pointChecks++; return false; };
w.pathBlockedByFence = () => { w.edgeChecks++; return directBlocked; };
return w;
}
function waypointFor(id) {
const w = makeWorld();
const actor = { id, x: 120, y: 120, radius: 20 };
const target = { id: "goal", x: 1120, y: 780 };
const first = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
if (!first) throw new Error(`no path for ${id}`);
const pointAfterFirst = w.pointChecks;
const edgeAfterFirst = w.edgeChecks;
const again = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
if (!again || first.x !== again.x || first.y !== again.y) throw new Error("same actor route cache is not stable");
if (w.pointChecks !== pointAfterFirst || w.edgeChecks !== edgeAfterFirst) throw new Error("throttled actor cache still performs geometry checks every call");
if (actor._routeCache?.spatialVersion !== 3 || actor._routeCache?.mode !== "grid") throw new Error("unified route cache was not stored");
if (actor._gridPathWaypoint || actor._pathWaypoint) throw new Error("legacy route caches still exist");
w.routingObstacleVersion = 4;
w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
if (actor._routeCache?.spatialVersion !== 4) throw new Error("cache did not invalidate after obstacle version change");
if (w.pointChecks > 18 * 18 * 3) throw new Error(`excessive point checks: ${w.pointChecks}`);
if (w.edgeChecks > 18 * 18 * 8 * 3) throw new Error(`excessive edge checks: ${w.edgeChecks}`);
return `${first.x.toFixed(3)},${first.y.toFixed(3)}`;
}
const routes = new Set(Array.from({ length: 64 }, (_, i) => waypointFor(`tarinai-${i}`)));
if (routes.size < 2) throw new Error("ID-fixed bias did not diversify independently computed routes");
const directWorld = makeWorld();
const directTarget = { id: "direct-goal", x: 600, y: 400 };
const directActor = { id: "direct-a", x: 100, y: 100, radius: 20 };
directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
const directChecks = directWorld.edgeChecks;
for (let i = 0; i < 100; i++) directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
if (directWorld.edgeChecks !== directChecks) throw new Error("direct-route throttle did not suppress repeated fence checks");
const w = makeWorld();
w.markSpatialDirty = World.prototype.markSpatialDirty;
console.log(`[OK] throttled actor-local route cache, obstacle invalidation, stable ID bias, and route diversity passed (${routes.size} first-waypoint variants)`);

View file

@ -1,58 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
const creatureSource = read("js/simulation_creature_system.js");
assert(creatureSource.includes("_creatureScheduledWorkCursor"), "rotating scheduled-work cursor is missing");
assert(creatureSource.includes("creatureCount >= 80"), "high-population guard for rotating AI work is missing");
assert(creatureSource.includes("perfProfile.aiBudget"), "AI budget is not used as the low-overhead rotation step");
assert(!creatureSource.includes("sortScheduledAi"), "AI spreading introduced an unnecessary sort queue");
console.log("[OK] high-cost creature work uses a rotating, queue-free budget order");
const renderSource = read("js/render.js");
assert(renderSource.includes("creatureSortRect = visibleWorldRect(world, 72)"), "narrow creature depth-sort rectangle is missing");
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect"), "Tarinai sort candidates are not limited before sorting");
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.antCells, creatureSortRect"), "ant sort candidates are not limited before sorting");
assert(renderSource.includes("collectVisibleRenderStack(world, visibleRect, creatureSortRect)"), "render stack does not receive the narrowed creature sort rectangle");
console.log("[OK] creature depth sorting is limited to near-viewport candidates before list insertion");
const relationSource = read("js/tarinai_identity_social.js");
assert(relationSource.includes("lazyDropDeadRelationPeer"), "lazy dead relationship deletion helper is missing");
assert(relationSource.includes("pruneDeadRelationshipRefs(deadIds)"), "dead-ID batch relationship cleanup is missing");
assert(!/entry\.ownCount\s*=\s*Math\.max\(entry\.ownCount,\s*Object\.keys\(this\.relationships\)\.length\)/.test(relationSource), "relation insertion still enumerates all relationship keys");
console.log("[OK] dead relationship references are lazily removed without per-insert full key enumeration");
class World {}
global.World = World;
global.TarinaiHistory = { trimMemory() { return 0; } };
require(path.join(ROOT, "js/world_spatial_budget.js"));
const world = new World();
world.time = 100;
const aliveA = { id: "A", dead: false, relationships: { D1: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 2 }, X: { affinity: 9 } }, relationCache: { stale: true } };
world.tarinai = [aliveA, aliveC];
world.liveTarinai = new Map([["A", { target: aliveA }], ["C", { target: aliveC }]]);
world._deadTarinaiIdsPendingCleanup = new Set(["D1"]);
world.tarinaiCollisionMemo = new Map();
world.relationNotices = {};
world.fightPairCooldowns = {};
world.resolvedFightIds = {};
world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true });
assert(world._tarinaiRuntimePruneJob, "threshold cleanup job was not queued");
world.processTarinaiRuntimeCachePruneStep(1);
assert(world._tarinaiRuntimePruneJob, "threshold cleanup was not split across frames");
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(1);
assert(!Object.prototype.hasOwnProperty.call(aliveA.relationships, "D1"), "known dead relationship was not removed");
assert(Object.prototype.hasOwnProperty.call(aliveA.relationships, "C"), "live relationship was incorrectly removed");
assert(Object.prototype.hasOwnProperty.call(aliveC.relationships, "X"), "unrelated stale reference was scanned/removed by targeted cleanup");
assert.strictEqual(world._deadTarinaiIdsPendingCleanup.size, 0, "processed dead-ID set was not released after chunked cleanup");
console.log("[OK] threshold cleanup is split across frames and removes only queued dead IDs");
const deathSource = read("js/tarinai_social_move_life.js");
assert(deathSource.includes("noteTarinaiDeathForRuntimeCleanup?.(this.id)"), "death hook does not queue the dead Tarinai ID");
console.log("Performance 5-7 regression audit passed.");

View file

@ -1,28 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(root, rel), "utf8");
const ok = (value, message) => { if (!value) throw new Error(message); console.log(`[OK] ${message}`); };
const budget = read("js/world_spatial_budget.js");
const family = read("js/world_family_social.js");
const life = read("js/tarinai_social_move_life.js");
const achievements = read("js/achievements.js");
const charts = read("js/ui_charts.js");
const main = read("js/main.js");
const render = read("js/render.js");
ok(budget.includes("rebuildTarinaiCountCache") && budget.includes("syncTarinaiCountEntry") && budget.includes("tarinaiCounts()"), "population counters use mutation-maintained differential cache");
ok(family.includes("syncTarinaiCountEntry?.(t)") && life.includes("syncTarinaiCountEntry?.(this)"), "birth/add and death paths update differential population counters");
ok(budget.includes('noteItemInactive(item, reason = "item-inactive", notifyAchievements = true)') && budget.includes('noteItemInactive?.(it, "compact-items", false)'), "bulk item compaction suppresses per-item achievement checks");
ok(budget.includes('evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" })'), "bulk item compaction emits one aggregate achievement event");
ok(achievements.includes("function evaluateEvent(worldRef, trigger") && achievements.includes('evaluateEvent(worldRef, "timer"'), "achievement evaluation has event-driven trigger entry point");
ok(!achievements.includes("const needsHabitatItemScan"), "playstyle habitat achievements no longer rescan every item on periodic evaluation");
ok(achievements.includes("worldRef?.itemCounts") && achievements.includes("playstyleItemCount"), "item-dependent achievements use differential item counts in normal runtime");
ok(charts.includes("function colonyStatsUiVisible()") && main.includes("!colonyStatsUiVisible()"), "hidden colony UI prevents scheduled stats aggregation");
ok(charts.includes("world.tarinaiCounts?.()") && charts.includes("collectDynamicColonyStats"), "visible stats separate hot counters from dynamic full-population aggregation");
ok(render.includes("nearbyRectInto(worldRef.spatial.tarinaiCells") && render.includes("isPointVisibleInRect") && render.includes("!isEntityVisibleInRect(entity, visibleRect)"), "render stack pre-culls entities before list insertion");
ok(!render.includes("isEntityVisibleInRect({ ...it, x: pose.x, y: pose.y }"), "carried-item visibility culling avoids temporary object allocation");
console.log("[OK] performance architecture: differential counters, event triggers, hidden UI gating, and pre-render culling verified");

View file

@ -1,77 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const ROOT = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
const render = read("js/render.js");
assert(!render.includes("insertionSortNearPrevious"), "custom insertion sort still exists");
assert(!render.includes("_renderPrevDynamicLayeredIds") && !render.includes("_renderBackSortMap"), "previous-order render bookkeeping still exists");
assert(render.includes("dynamicBack.sort(compareBackItems)") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "native render sort is not active");
console.log("[OK] previous-order Map and custom insertion sort are removed");
const pathfinding = read("js/world_pathfinding_system.js");
assert(!pathfinding.includes("_sharedRouteCache") && !pathfinding.includes("trySharedRoute") && !pathfinding.includes("storeSharedRoute"), "shared route cache still exists");
assert(pathfinding.includes("actor._routeCache"), "actor-local route cache was removed accidentally");
console.log("[OK] shared route cache is removed while actor-local throttling remains");
const profiler = read("js/perf_profiler.js");
const budget = read("js/world_spatial_budget.js");
assert(profiler.includes("diagnosticsEnabled"), "diagnostic-mode gate is missing");
assert(budget.includes("diagnostics ?") && budget.includes("this.workStats = diagnostics ?"), "production detailed diagnostic allocation is still unconditional");
assert(profiler.includes("transitionHoldSeconds") && profiler.includes("pendingProfileSeconds"), "performance-profile hysteresis hold timers are missing");
console.log("[OK] production diagnostics are gated and auto profile switching uses hysteresis");
const environment = read("js/world_environment.js");
assert(environment.includes("`${this.routingObstacleVersion || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`"), "obstacle cache is not keyed by routingObstacleVersion only");
assert(!environment.includes("`${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx}"), "obstacle cache still invalidates on generic spatial movement");
console.log("[OK] obstacle cache invalidation is isolated from generic spatial movement");
const simCore = read("js/sim_core.js");
const start = simCore.indexOf("class SpatialGrid {");
const end = simCore.indexOf("\nfunction drawHeartShape", start);
assert(start >= 0 && end > start, "SpatialGrid source could not be isolated");
const context = { console, Map, Set, Math, Number, Array, globalThis: {} };
vm.createContext(context);
vm.runInContext(`${simCore.slice(start, end)}\nglobalThis.SpatialGrid = SpatialGrid;`, context);
const SpatialGrid = context.globalThis.SpatialGrid;
const grid = new SpatialGrid(100);
const a = { id: "a", x: 10, y: 10, dead: false };
const b = { id: "b", x: 40, y: 40, dead: false };
grid.rebuildTarinai([a, b]);
const firstKey = a._spatialTarinaiCellKey;
a.x = 50;
assert.strictEqual(grid.syncTarinai([a, b]), 0, "same-cell movement should not rewrite the grid");
a.x = 150;
assert.strictEqual(grid.syncTarinai([a, b]), 1, "cell crossing should update exactly one entity");
assert.notStrictEqual(a._spatialTarinaiCellKey, firstKey, "cell key did not change after crossing");
b.dead = true;
assert(grid.syncTarinai([a, b]) >= 1, "dead entity was not removed incrementally");
assert(!grid._indexedTarinai.has(b), "dead entity remains in the Tarinai spatial index");
console.log("[OK] Tarinai spatial grid updates only cell crossings/removals instead of full rebuilds");
class World {}
global.World = World;
global.TarinaiHistory = { trimMemory() { return 0; } };
require(path.join(ROOT, "js", "world_spatial_budget.js"));
const world = new World();
world.time = 100;
world.tarinai = Array.from({ length: 120 }, (_, i) => ({ id: `T${i}`, dead: false, relationships: { D: { affinity: 1 } } }));
world.liveTarinai = new Map(world.tarinai.map(t => [t.id, { target: t }]));
world._deadTarinaiIdsPendingCleanup = new Set(["D"]);
world.tarinaiCollisionMemo = new Map();
world.relationNotices = {};
world.fightPairCooldowns = {};
world.resolvedFightIds = {};
assert(world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true }), "cleanup job was not queued");
world.processTarinaiRuntimeCachePruneStep(24);
assert(world._tarinaiRuntimePruneJob, "cleanup finished in one frame instead of being chunked");
assert.strictEqual(world._tarinaiRuntimePruneJob.cursor, 24, "cleanup chunk size was not respected");
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(24);
assert(world.tarinai.every(t => !Object.prototype.hasOwnProperty.call(t.relationships, "D")), "chunked cleanup left dead references behind");
console.log("[OK] death-reference cleanup runs in bounded per-frame chunks");
console.log("Performance cleanup regression audit passed.");

View file

@ -1,232 +0,0 @@
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const root = path.join(__dirname, "..");
const source = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
class ClassList {
constructor() { this.values = new Set(["hidden"]); }
add(...names) { names.forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) { const next = force === undefined ? !this.values.has(name) : Boolean(force); if (next) this.values.add(name); else this.values.delete(name); return next; }
}
class Element {
constructor(id = "") { this.id = id; this.className = ""; this.classList = new ClassList(); this.dataset = {}; this.styles = new Map(); this.style = { setProperty: (n,v)=>this.styles.set(n,v), removeProperty:n=>this.styles.delete(n) }; this.children=[]; this.textContent=""; this.tabIndex=-1; this.isConnected=true; this.hidden=true; }
append(...children){this.children.push(...children);} replaceChildren(...children){this.children=children;} setAttribute(){} addEventListener(){} focus(){} getBoundingClientRect(){return{left:0,top:0,width:100,height:30};} get offsetWidth(){return 100;}
}
function harness(storage = new Map()) {
const elements = new Map();
["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"].forEach(id=>elements.set(id,new Element(id)));
let timer=0;
const c={console,Date,Intl,Math,JSON,Object,Array,Map,Set,WeakMap,Promise,Number,String,Boolean,Error,AbortController,encodeURIComponent,URLSearchParams,
location:{protocol:"file:",search:""},crypto:{randomUUID:()=>"11111111-1111-4111-8111-111111111111"},
localStorage:{getItem:k=>storage.has(k)?storage.get(k):null,setItem:(k,v)=>storage.set(k,String(v)),removeItem:k=>storage.delete(k)},
document:{hidden:false,getElementById:id=>elements.get(id)||null,createElement:()=>new Element(),addEventListener(){}},
TarinaiEvents:{emit(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.65"};
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
return {c,api:c.TarinaiAchievements,storage,elements};
}
function assert(v,m){if(!v)throw new Error(m);} function off(h,id){return !h.api.isUnlocked(id);} function on(h,id){return h.api.isUnlocked(id);}
function alive(n){return Array.from({length:n},(_,i)=>({id:`t${i}`,dead:false,x:i,y:0}));}
function findCard(h,id){const stack=[h.elements.get("achievementList")];while(stack.length){const node=stack.shift();if(node?.dataset?.achievementId===id)return node;stack.push(...(node?.children||[]));}return null;}
(function run(){
let h=harness();
assert(h.api.definitions.length===76,"must have 76 achievements");
h.api.open();
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
assert(title.mercury_lifespan==="不老不死まであと一歩","mercury title");
const description=(id)=>findCard(h,id).children[1].children[0].children[1].textContent;
assert(description("robot_cleaner_100").includes("ずんち・死骸・汚れを累計200個処理させる。"),"cleaner condition disclosed in UI");
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
assert(description("overprotective")==="","other new condition hidden");
assert(description("elite_few").includes("25体以下の状態を5日間維持"),"elite few condition must be visible before unlock");
// One Undo only: multiple smaller Undo operations never aggregate.
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
assert(off(h,"undo_mass_revival"),"mass undo aggregated across operations");
h.api.recordHistoryAction("undo",{deadRestored:10}); assert(on(h,"undo_mass_revival"),"mass undo boundary");
// Undo/Redo counters persist across field resets and reloads.
let storage=new Map(); h=harness(storage);
for(let i=0;i<12;i++)h.api.recordHistoryAction("undo",{});
h.api.resetWorldProgress({});
h=harness(storage); for(let i=0;i<7;i++)h.api.recordHistoryAction("undo",{});
assert(off(h,"undo_20"),"undo unlocked at 19"); h.api.recordHistoryAction("undo",{}); assert(on(h,"undo_20"),"undo did not persist to 20");
for(let i=0;i<11;i++)h.api.recordHistoryAction("redo",{}); h=harness(storage); for(let i=0;i<8;i++)h.api.recordHistoryAction("redo",{});
assert(off(h,"redo_20"),"redo unlocked at 19"); h.api.recordHistoryAction("redo",{}); assert(on(h,"redo_20"),"redo did not persist to 20");
// Cleaner is field-local.
h=harness(); const wa={time:0},wb={time:0}; h.api.recordRobotClean({world:wa,type:"tarinai"}); assert((wa.achievementRobotCleanCount||0)===0,"cleaner counted non-cleanup target");
for(let i=0;i<199;i++)h.api.recordRobotClean({world:wa,type:i%2?"zunchi":"trace"});
assert(off(h,"robot_cleaner_100"),"cleaner before 200"); h.api.recordRobotClean({world:wb,type:"ant_corpse"}); assert(off(h,"robot_cleaner_100"),"cleaner crossed field reset");
h.api.recordRobotClean({world:wa,type:"splat"}); assert(on(h,"robot_cleaner_100"),"cleaner at 200");
// Continuous pinch must be uninterrupted and same individual.
h=harness(); const held={dead:false,playerHeld:true}; const hw={tarinai:[held]};
h.api.recordHeldTarinai(hw,1000); h.api.recordHeldTarinai(hw,30999); assert(off(h,"held_30_seconds"),"held before 30s");
held.playerHeld=false; h.api.recordHeldTarinai(hw,31000); held.playerHeld=true; h.api.recordHeldTarinai(hw,40000); h.api.recordHeldTarinai(hw,69999); assert(off(h,"held_30_seconds"),"held did not reset after release");
h.c.document.hidden=true; h.api.recordHeldTarinai(hw,70000); h.c.document.hidden=false;
h.api.recordHeldTarinai(hw,70001); h.api.recordHeldTarinai(hw,100000); assert(off(h,"held_30_seconds"),"hidden tab time counted toward hold");
h.api.recordHeldTarinai(hw,100001); assert(on(h,"held_30_seconds"),"held at 30s after visible reset");
// Minimalist counts only surviving player placements.
h=harness(); let mw={time:0,day:5,tarinai:alive(1),ants:[],items:Array.from({length:11},()=>({_achievementPlayerPlaced:true,dead:false}))};
h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist allowed 11 items");
mw.items[10].dead=true; h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist unlocked before day 6");
mw.day=6; h.api.evaluateWorld(mw,{id:"happy"}); assert(on(h,"minimalist_happy"),"minimalist at day 6 with 10 items");
// Overprotective requires both thresholds on the same individual.
h=harness(); const cared={dead:false}; const cw={time:0};
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",beneficialRecovery:true});
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
h=harness(); let sw={time:0,tarinai:alive(20),ants:[],items:[]}; h.api.evaluateWorld(sw,{}); sw.time=599.99;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient early");
sw.items.push({type:"duplicator",dead:false});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient with duplicator");
sw.items=[];sw.time=700;h.api.evaluateWorld(sw,{});sw.time=1300;h.api.evaluateWorld(sw,{});assert(on(h,"self_sufficient"),"self sufficient after clean 10m");
h=harness(); sw={time:0,tarinai:alive(20),ants:[],items:[]};h.api.evaluateWorld(sw,{});sw.time=500;h.api.recordDirectFeed({world:sw,tarinai:sw.tarinai[0],type:"food"});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"direct feed did not reset self sufficient");
// Unplanned city is persistent and deletion must be within 30 seconds.
storage=new Map(); h=harness(storage); let qw={time:0};
for(let i=0;i<15;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
h=harness(storage);qw={time:0};for(let i=0;i<14;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
h=harness(); qw={time:0}; for(let i=0;i<30;i++){const x={};h.api.recordPlayerPlacement({world:qw,item:x});h.api.recordPlayerDeletion({world:qw,item:x,operationId:"same-area-op"});} assert(off(h,"unplanned_city_30"),"area deletion counted each removed item instead of one operation");
// Sauna -> cold within 15 seconds; cold alone must never unlock.
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
temp=0;h.api.evaluateWorld(tw,{});assert(off(h,"sauna_cold_plunge"),"cold alone unlocked sauna");temp=40;h.api.evaluateWorld(tw,{});tw.time=15;temp=0;h.api.evaluateWorld(tw,{});assert(on(h,"sauna_cold_plunge"),"sauna boundary 15s");
h=harness(); const st2={dead:false,x:0,y:0};temp=40;const tw2={time:0,tarinai:[st2],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":"cold",comfortable:false};}};h.api.evaluateWorld(tw2,{});tw2.time=15.01;temp=0;h.api.evaluateWorld(tw2,{});assert(off(h,"sauna_cold_plunge"),"sauna allowed over 15s");
// Rain shelter requires all 20 and accepts nest/pipe via shelter system.
h=harness(); h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};
const rw={time:0,weather:"light_rain",tarinai:alive(20).map(t=>({...t,sheltered:true})),ants:[],items:[]};h.api.evaluateWorld(rw,{});assert(on(h,"rain_shelter_all"),"all sheltered rain achievement");
h=harness();h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};rw.tarinai[19].sheltered=false;h.api.evaluateWorld(rw,{});assert(off(h,"rain_shelter_all"),"rain achievement allowed one exposed");
// Nest boxes and pipes are genuine weather blockers, not only achievement stubs.
h=harness();
vm.runInContext(fs.readFileSync(path.join(root,"js","tarinai_local_environment_system.js"),"utf8"),h.c,{filename:"tarinai_local_environment_system.js"});
const nest={id:"n1",type:"nest_box",dead:false};
const pipe={id:"p1",type:"pipe",dead:false};
const shelterWorld={items:[nest,pipe],nestBoxById(id){return this.items.find(x=>x.id===id)||null;}};
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"n1"}),"nest box did not block weather");
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"p1"}),"pipe did not block weather");
assert(!h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{}),"unsheltered target treated as sheltered");
// Nest-box and pipe occupants must also be insulated from weather temperature offsets.
{
class FakeWorld {}
const tc={console,Math,Number,Object,Array,Set,Map,WeakMap,Date,CONFIG:{standardTemperature:15,temperatureItemRange:190},
World:FakeWorld,clamp:(v,a,b)=>Math.max(a,Math.min(b,v)),distXY:(x1,y1,x2,y2)=>Math.hypot(x1-x2,y1-y2),
TarinaiParasolSystem:h.c.TarinaiRainShelterSystem};
tc.window=tc;tc.globalThis=tc;vm.createContext(tc);
vm.runInContext(fs.readFileSync(path.join(root,"js","world_temperature_system.js"),"utf8"),tc,{filename:"world_temperature_system.js"});
const box={id:"box",type:"nest_box",dead:false,x:0,y:0,r:42};
const pipeItem={id:"pipe",type:"pipe",dead:false,x:0,y:0,r:42};
const weatherWorld=new FakeWorld();Object.assign(weatherWorld,{currentTemperature:15,temperatureAuto:true,groundType:"soil",weather:"light_rain",items:[box,pipeItem],
nearbyItems(x,y,r){return this.items.filter(it=>Math.hypot(it.x-x,it.y-y)<=r);},nestBoxById(id){return this.items.find(x=>x.id===id)||null;},nestBoxBaseRect(){return null;}});
const outside={x:500,y:0}; const inBox={x:0,y:0,insideNestBoxId:"box"}; const inPipe={x:0,y:0,insideNestBoxId:"pipe"};
assert(weatherWorld.feltTemperatureFor(outside)<15,"rain weather offset missing outside");
assert(weatherWorld.feltTemperatureFor(inBox)>=15,"nest box still received rain temperature offset");
assert(weatherWorld.feltTemperatureFor(inPipe)>=15,"pipe still received rain temperature offset");
}
// Every medicine item once; chain and crown are explicitly excluded.
h=harness(); const meds=["first_aid","sedative","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug"];
meds.slice(0,-1).forEach(type=>h.api.recordConsumableUse({type}));assert(off(h,"medicine_ledger_all"),"medicine ledger early");h.api.recordConsumableUse({type:meds.at(-1)});assert(on(h,"medicine_ledger_all"),"medicine ledger complete");
// Mercury lifespan, revolution, fight mochi.
h=harness();h.api.recordDeath({world:{time:1,tarinai:[]},tarinai:{lifeItemMode:"mercury"},reason:"天寿を全うした"});assert(on(h,"mercury_lifespan"),"mercury lifespan");
h=harness();const slave={id:"s",isZunchiSlave:true,dead:false};const king={id:"k",isTarinaiChampion:true,dead:true,_achievementLastFightAttackerId:"s",_achievementLastFightDamageAt:5};const revWorld={time:5,tarinai:[slave,king]};h.api.recordDeath({world:revWorld,tarinai:king,reason:"喧嘩",fromDamage:true});assert(on(h,"revolution"),"revolution");
h=harness();const oil={};const fw={time:0};h.api.recordConsumableUse({world:fw,tarinai:oil,type:"fight_mochi"});fw.time=30;h.api.recordFightStarted({world:fw,a:oil,b:{}});assert(on(h,"fuel_to_fire"),"fight mochi at 30s");
h=harness();const noOil={};h.api.recordFightStarted({world:{time:0},a:noOil,b:{}});assert(off(h,"fuel_to_fire"),"fight without mochi unlocked at time zero");
h=harness();const oil2={};const fw2={time:0};h.api.recordConsumableUse({world:fw2,tarinai:oil2,type:"fight_mochi"});fw2.time=30.01;h.api.recordFightStarted({world:fw2,a:oil2,b:{}});assert(off(h,"fuel_to_fire"),"fight mochi over 30s");
// Enemy's enemy: 30 alive, danger-item kills, and any dangerous tarinai hit resets the streak.
h=harness(); const ew={time:20,tarinai:alive(30)};
for(let i=0;i<9;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");
h.api.recordDamageSource(ew.tarinai[0],{type:"firecracker"},{world:ew});
assert((ew.achievementEnemyAntKills||0)===0,"dangerous tarinai damage did not reset enemy streak");
for(let i=0;i<10;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
assert(on(h,"enemy_enemy_friend"),"enemy achievement did not unlock after 10 clean danger kills");
h=harness();const ew2={time:20,tarinai:alive(30)};
for(let i=0;i<5;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}
ew2.tarinai[0].dead=true;const resetAnt={world:ew2};h.api.recordAntDamageSource(resetAnt,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:resetAnt});
assert((ew2.achievementEnemyAntKills||0)===0,"population below 30 did not reset enemy streak");
h=harness();const ewDip={time:20,tarinai:alive(30)};
for(let i=0;i<5;i++){const ant={world:ewDip};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ewDip});h.api.recordAntKilled({world:ewDip,ant});}
ewDip.tarinai[0].dead=true;h.api.recordDeath({world:ewDip,tarinai:ewDip.tarinai[0],reason:"天寿を全うした"});
assert((ewDip.achievementEnemyAntKills||0)===0,"population dip without an ant kill preserved the enemy streak");
ewDip.tarinai.push({dead:false});const afterDip={world:ewDip};h.api.recordAntDamageSource(afterDip,{type:"firecracker"},{world:ewDip});h.api.recordAntKilled({world:ewDip,ant:afterDip});
assert((ewDip.achievementEnemyAntKills||0)===1,"enemy streak did not restart from one after population recovery");
const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert((ew2.achievementEnemyAntKills||0)===0,"non-danger ant kill counted");
h=harness();const ew3={time:20,tarinai:alive(30)};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
// New observation and sticky-bomb relay achievements.
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
// New playstyle-survey achievements.
h=harness(); let popWorld={time:0,day:1,tarinai:alive(99),ants:[],items:[]}; h.api.evaluateWorld(popWorld,{}); assert(off(h,"strength_in_numbers"),"strength in numbers unlocked before 100"); popWorld.tarinai.push({id:"t99",dead:false,x:99,y:0}); h.api.evaluateWorld(popWorld,{}); assert(on(h,"strength_in_numbers"),"strength in numbers did not unlock at 100");
h=harness(); let eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=599.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few unlocked before five days"); eliteWorld.time=600; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not unlock at five days");
h=harness(); eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=300; eliteWorld.tarinai.push({dead:false}); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.tarinai.pop(); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=899.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few timer survived population overflow"); eliteWorld.time=900; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not restart after overflow");
h=harness(); let zWorld={time:0,tarinai:alive(20),ants:[],items:Array.from({length:20},()=>({type:"zunchi",dead:false}))}; h.api.evaluateWorld(zWorld,{}); assert(off(h,"zunchi_overflow"),"zunchi overflow unlocked at equal count"); zWorld.items.push({type:"zunchi",dead:false}); h.api.evaluateWorld(zWorld,{}); assert(on(h,"zunchi_overflow"),"zunchi overflow did not unlock above population");
h=harness(); let bedWorld={time:0,tarinai:alive(5),ants:[],items:[{type:"nest_box",dead:false}],nestBoxCapacity(){return 5;}}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"comfortable_beds"),"comfortable beds did not count nest capacity");
h=harness(); bedWorld={time:0,tarinai:alive(30),ants:[],items:[]}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"stone_pillow"),"stone pillow did not unlock at 30 with zero bed capacity");
h=harness(); const seasonWorld={time:2400,elapsedDays:20,tarinai:alive(1),ants:[],items:[]}; h.api.evaluateWorld(seasonWorld,{}); assert(on(h,"across_seasons"),"across seasons did not unlock after full cycle");
h=harness(); const fav={dead:false,favorite:true}; h.api.recordFavoriteTarinai({tarinai:fav}); assert(on(h,"favorite_one"),"favorite achievement did not unlock");
storage=new Map(); h=harness(storage); for(let i=0;i<15;i++) h.api.recordStatsButtonPress({control:"chart:population"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<14;i++) h.api.recordStatsButtonPress({control:"series:pop"}); assert(off(h,"statistician"),"statistician unlocked at 29 across reset"); h.api.recordStatsButtonPress({control:"chart:life"}); assert(on(h,"statistician"),"statistician did not persist across reset to 30");
storage=new Map(); h=harness(storage); for(let i=0;i<60;i++) h.api.recordManualTarinaiAdded({tool:"new"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<39;i++) h.api.recordManualTarinaiAdded({tool:"new"}); assert(off(h,"lively_making"),"lively making unlocked at 99 across reset"); h.api.recordManualTarinaiAdded({tool:"new"}); assert(on(h,"lively_making"),"lively making did not persist across reset to 100");
// Static integration guards for all production hook points.
const checks={
"js/command_dispatcher.js":["deadRestored","recordHistoryAction","recordFavoriteTarinai"],
"js/robot_cleaner_system.js":["recordRobotClean"],
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
"js/tarinai_social_move_life.js":["_achievementLastFightAttackerId"],
"js/tarinai_direct_feeding_system.js":["recordDirectCare"],
"js/tarinai_consumable_behavior.js":["recordConsumableUse"],
"js/tarinai_contact_item_system.js":["recordConsumableUse", "slave_chain", "golden_crown"],
"js/world_combat_effects.js":["recordAntDamageSource","source: it"],
"js/impact_response_system.js":["source: it"],
"js/ants.js":["recordAntDamageSource", "tarinai_struggle"],
"js/signal_system.js":["recordAntDamageSource", "electric_wire"],
"js/tarinai_item_effects.js":["recordAntDamageSource", "tarinai_ammo_collision"],
"js/tarinai_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
"js/tarinai_disease_nest.js":["isSheltered?.(this.world, this.x, this.y, this)"],
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
"js/snapshot_system.js":["stickyBombPassCount"],
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
"js/ui_bind.js":["recordStatsButtonPress","chart:","series:"],
};
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
})();
{
const achSrc = fs.readFileSync("js/achievements.js", "utf8");
const ecologyBlock = achSrc.match(/id: "ecology"[\s\S]*?id: "population"/)?.[0] || "";
const otherBlock = achSrc.match(/id: "other"[\s\S]*?const DEFINITIONS/)?.[0] || "";
assert(!ecologyBlock.includes('"across_seasons"'), "across_seasons category remained ecology");
assert(otherBlock.includes('"across_seasons"'), "across_seasons category is not その他");
}

View file

@ -1,194 +0,0 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
// The removed load-settle feature may only be explicitly cleared for stale
// runtime state; it must never be scheduled or checked as active behavior.
const snapshotSource = read("js/snapshot_system.js");
const settleSources = [
"js/collision_response_system.js",
"js/tarinai_social_move_life.js",
"js/tarinai_update_step_movement.js",
"js/world_environment.js",
].map(read).join("\n");
assert(!/_restoreSettleUntil\s*=/.test(snapshotSource), "load settle timer is still assigned");
assert(!/_restoreSettleUntil/.test(settleSources), "load settle behavior is still checked outside snapshot cleanup");
assert(snapshotSource.includes("delete worldRef._restoreSettleUntil"), "stale settle state is not cleared on restore");
// Colony population statistics must expose both special populations.
const chartsSource = read("js/ui_charts.js");
assert(chartsSource.includes('key: "zunchiSlaves"') && chartsSource.includes('label: "\\u305a\\u3093\\u3061\\u3069\\u308c\\u3044"'), "zunchi slave population series is missing");
assert(chartsSource.includes('key: "tarinaiKings"') && chartsSource.includes('label: "\\u305f\\u308a\\u306a\\u3044\\u738b"'), "tarinai king population series is missing");
assert(chartsSource.includes("world.tarinaiCounts?.()") && chartsSource.includes("counts.zunchiSlaves") && chartsSource.includes("counts.tarinaiKings"), "special population counters are not sourced from differential live counters");
assert(chartsSource.indexOf('key: "deaths"') < chartsSource.indexOf('key: "zunchiSlaves"'), "zunchi slave button must appear after deaths");
assert(chartsSource.indexOf('key: "zunchiSlaves"') < chartsSource.indexOf('key: "tarinaiKings"'), "tarinai king button must follow zunchi slave");
const maintenanceSource = read("js/simulation_maintenance_system.js");
assert(!maintenanceSource.includes("periodic-runtime-cache-prune"), "periodic full runtime-cache prune is still enabled");
class World {}
global.World = World;
let historyTrimCalls = 0;
global.TarinaiHistory = {
trimMemory(world, options) {
historyTrimCalls += 1;
world._lastTrimOptions = options;
return 0;
},
};
require(path.join(ROOT, "js/family_graph.js"));
require(path.join(ROOT, "js/world_spatial_budget.js"));
require(path.join(ROOT, "js/world_family_social.js"));
function makeWorld() {
const world = new World();
world.time = 100;
world.tarinai = [];
world.spatial = { tarinaiCells: new Map([["old", [1, 2, 3]]]) };
world.markSpatialDirty = () => { world._spatialDirtyMarked = true; };
world.markFamilyTreeDirty = () => {};
world.normalizeFamily = () => false;
return world;
}
// Runtime cache pruning is death-triggered: small numbers of deaths must not
// schedule a full survivor relationship scan, while the adaptive threshold does.
{
const world = makeWorld();
world.liveTarinai = new Map(Array.from({ length: 100 }, (_, i) => [`T${i}`, { target: { id: `T${i}`, dead: false } }]));
for (let i = 0; i < 19; i++) world.noteTarinaiDeathForRuntimeCleanup();
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "cleanup scheduled before the minimum 20-death threshold");
world.noteTarinaiDeathForRuntimeCleanup();
assert.strictEqual(world.tarinaiRuntimePrunePending, true, "cleanup was not scheduled at the death threshold");
}
// A pending death-threshold cleanup executes once at compaction and then resets.
{
const world = makeWorld();
const alive = Array.from({ length: 100 }, (_, i) => ({ id: `A${i}`, dead: false, relationships: {} }));
const dead = Array.from({ length: 20 }, (_, i) => ({ id: `D${i}`, dead: true, relationships: {} }));
world.tarinai = [...alive, ...dead];
world.liveTarinai = new Map(alive.map(t => [t.id, { target: t }]));
world.deadTarinaiSinceRuntimePrune = 20;
world.tarinaiRuntimePrunePending = true;
world._deadTarinaiIdsPendingCleanup = new Set(dead.map(t => t.id));
world.compactTarinai();
assert(world._tarinaiRuntimePruneJob, "pending runtime cleanup was not queued during compaction");
assert.strictEqual(world.deadTarinaiSinceRuntimePrune, 0, "death cleanup counter was not reset when the job was queued");
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "death cleanup pending flag was not cleared when queued");
let steps = 0;
while (world._tarinaiRuntimePruneJob && steps++ < 10) world.processTarinaiRuntimeCachePruneStep(24);
assert(!world._tarinaiRuntimePruneJob, "queued runtime cleanup did not finish in bounded chunks");
}
// Runtime caches must stop retaining dead peers and expired peak-population keys.
{
const world = makeWorld();
const aliveA = { id: "A", dead: false, relationships: { B: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
const deadB = { id: "B", dead: true, relationships: {} };
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 3 } } };
world.tarinai = [aliveA, deadB, aliveC];
world.liveTarinai = new Map([["A", { target: aliveA }], ["B", { target: deadB }], ["ghost", { target: null }]]);
world.tarinaiCollisionMemo = new Map([["old", 90], ["recent", 99.5]]);
world.relationNotices = { old: -50, recent: 50 };
world.fightPairCooldowns = { expired: 99, future: 110 };
world.resolvedFightIds = { stale: true };
world._lastResolvedFightIdsResetAt = 0;
world._deadTarinaiIdsPendingCleanup = new Set(["B"]);
world.pruneTarinaiRuntimeCaches("audit", { resetRelationPeerCaches: true });
assert.deepStrictEqual(Object.keys(aliveA.relationships), ["C"], "dead relationship peer was retained");
assert.strictEqual(aliveA.relationCache, null, "relationship hot cache was not invalidated");
assert.deepStrictEqual([...world.liveTarinai.keys()], ["A"], "dead live-id cache entries were retained");
assert.deepStrictEqual([...world.tarinaiCollisionMemo.keys()], ["recent"], "stale collision memo was retained");
assert.deepStrictEqual(Object.keys(world.relationNotices), ["recent"], "stale relation notice was retained");
assert.deepStrictEqual(Object.keys(world.fightPairCooldowns), ["future"], "expired fight cooldown was retained");
assert.deepStrictEqual(world.resolvedFightIds, {}, "old resolved-fight keys were retained");
}
// A large population collapse must recreate high-water scratch containers and
// release large undo snapshots rather than keeping the peak footprint forever.
{
const world = makeWorld();
const oldRenderScratch = new Array(400).fill(null);
const oldRenderSeen = new Set(Array.from({ length: 400 }, (_, i) => `id-${i}`));
world._renderVisibleTarinaiScratch = oldRenderScratch;
world._renderVisibleSeen = oldRenderSeen;
world.tarinaiCollisionMemo = new Map([["peak", 100]]);
world.relationNotices = { peak: 100 };
world.resolvedFightIds = { peak: true };
world.fightPairCooldowns = { peak: 999 };
world._solidObstacleRectQueryCache = new Map([["peak", []]]);
world.spatialTarinaiScratch = new Array(400).fill(null);
world.drawList = new Array(400).fill(null);
world._tarinaiRuntimeHighWater = 400;
world.tarinai = Array.from({ length: 400 }, (_, i) => ({ id: `T${i}`, dead: i >= 200, relationships: {} }));
const changed = world.compactTarinai();
assert.strictEqual(changed, true, "population compaction did not run");
assert(world._tarinaiRuntimePruneJob, "population collapse did not queue an aggressive cleanup job");
let shrinkSteps = 0;
while (world._tarinaiRuntimePruneJob && shrinkSteps++ < 20) world.processTarinaiRuntimeCachePruneStep(32);
assert(!world._tarinaiRuntimePruneJob, "aggressive population cleanup did not complete in chunks");
assert.strictEqual(world.tarinai.length, 200, "dead tarinai were not compacted");
assert.strictEqual(world._tarinaiRuntimeHighWater, 200, "population high-water mark did not collapse");
assert.notStrictEqual(world._renderVisibleTarinaiScratch, oldRenderScratch, "render scratch retained peak backing store");
assert.notStrictEqual(world._renderVisibleSeen, oldRenderSeen, "render seen-set retained peak entries");
assert.strictEqual(world.tarinaiCollisionMemo.size, 0, "collision peak cache survived aggressive shrink");
assert(historyTrimCalls > 0, "history snapshots were not memory-trimmed after population collapse");
assert.strictEqual(world._lastTrimOptions.targetBytes, 8 * 1024 * 1024, "population-collapse history target is incorrect");
}
function node(id, generation, alive, parents = [], children = []) {
return { id, generation, alive, parents: [...parents], children: [...children], hasPaired: true };
}
// A dead generation is protected while an older generation above it is alive.
{
const world = makeWorld();
world.family = {
A: node("A", 1, true, [], ["B"]),
B: node("B", 2, false, ["A"], ["C"]),
C: node("C", 3, true, ["B"], []),
};
world.tarinai = [{ id: "A", dead: false, parents: [], children: ["B"] }, { id: "C", dead: false, parents: ["B"], children: [] }];
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), false, "dead generation was pruned despite a living older generation");
assert.deepStrictEqual(Object.keys(world.family).sort(), ["A", "B", "C"]);
}
// Once all older generations are extinct, extinct leading generations are removed.
{
const world = makeWorld();
world.family = {
A: node("A", 1, false, [], ["B"]),
B: node("B", 2, false, ["A"], ["C"]),
C: node("C", 3, true, ["B"], []),
};
world.tarinai = [{ id: "C", dead: false, parents: ["B"], children: [] }];
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true, "extinct leading generations were not pruned");
assert.deepStrictEqual(Object.keys(world.family), ["C"], "unexpected family generations remained after pruning");
assert.deepStrictEqual(world.family.C.parents, [], "survivor retained a deleted parent link");
assert.deepStrictEqual(world.tarinai[0].parents, [], "live tarinai retained a deleted parent link");
}
// A fully extinct disconnected family component may be dropped completely.
{
const world = makeWorld();
world.family = {
A: node("A", 1, false, [], ["B"]),
B: node("B", 2, false, ["A"], []),
X: node("X", 1, true, [], ["Y"]),
Y: node("Y", 2, true, ["X"], []),
};
world.tarinai = [{ id: "X", dead: false, parents: [], children: ["Y"] }, { id: "Y", dead: false, parents: ["X"], children: [] }];
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true);
assert.deepStrictEqual(Object.keys(world.family).sort(), ["X", "Y"], "fully extinct component was retained or living component was removed");
}
console.log("Population recovery regression audit passed.");

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
const spatial = fs.readFileSync(path.join(root, "js", "sim_core.js"), "utf8");
const budget = fs.readFileSync(path.join(root, "js", "world_spatial_budget.js"), "utf8");
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
ok(render.includes("_renderStaticVisibleCache") && render.includes("renderStaticVersion"), "static visible render stacks are version-cached");
ok(!render.includes("insertionSortNearPrevious") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "dynamic sorting uses native sort without previous-order bookkeeping");
ok(render.includes("mergeSortedRenderEntries") && render.includes("mergeSortedBackItems"), "static and dynamic sorted stacks are merged linearly");
ok(spatial.includes("linkRenderCells") && spatial.includes("addLinkRenderItem") && spatial.includes("rebuildLinkRenderItems"), "links use dedicated AABB render spatial cells");
ok(!render.includes("for (const type of LINK_RENDER_TYPES)"), "per-frame full link type fallback scan is removed");
ok(render.includes("drawCarriedPlushieAtOwner") && !render.includes("syncCarriedPlushieToOwner"), "carried plushie rendering no longer synchronizes simulation coordinates");
ok(render.includes("entity._renderCullRadius = value"), "eligible fixed cull radii are cached");
ok(render.includes("it._renderSortY = renderLayerSortY(it)"), "back-layer sort keys are computed before sorting");
ok(budget.includes("this.renderStaticVersion = (this.renderStaticVersion || 0) + 1"), "static spatial rebuilds invalidate the render cache");

View file

@ -1,26 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = rel => fs.readFileSync(path.join(root, rel), "utf8");
const ok = (cond, label) => {
if (!cond) throw new Error(`[FAIL] ${label}`);
console.log(`[OK] ${label}`);
};
const data = read("js/data.js");
const render = read("js/render.js");
const tarinaiRender = read("js/tarinai_render.js");
const history = read("js/history_system.js");
ok(/reproductionCooldown:\s*39\b/.test(data), "reproduction cooldown reduces the current reproduction pace by about 30 percent");
ok(/normalMetrics\s*=\s*getImageMetrics\("smile"\)/.test(tarinaiRender), "birth ritual sprite is normalized to ordinary tarinai bounds");
ok(!/if \(sprite === "birth_ritual"\) \{\s*drawW \*= 0\.64;\s*drawH \*= 0\.64;/s.test(tarinaiRender), "legacy oversized ritual scalar is removed");
ok(/const STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"plushie"[\s\S]*\]\);/.test(render), "expensive structural bitmap cache remains enabled");
ok(!/STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"zunchi"[\s\S]*\]\);/.test(render), "high-count zunchi no longer allocate per-item bitmap canvases");
ok(!/STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"food"[\s\S]*\]\);/.test(render), "high-count consumables no longer allocate per-item bitmap canvases");
ok(/function releaseStaticItemBitmapCache\(/.test(render) && /cache\.canvas\.width = 0;/.test(render), "obsolete item bitmap backing buffers are explicitly released");
ok(/MAX_HISTORY = 32/.test(history) && /MAX_HISTORY_BYTES = 8 \* 1024 \* 1024/.test(history), "undo history is capped at 32 entries and 8 MiB");
console.log("Reproduction/memory/sprite regression audit passed.");

View file

@ -1,27 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = p => fs.readFileSync(path.join(root, p), "utf8");
const creature = read("js/simulation_creature_system.js");
const frame = read("js/tarinai_update_step_frame.js");
const social = read("js/tarinai_social_move_life.js");
const achievements = read("js/achievements.js");
const needs = read("js/tarinai_needs_items.js");
const socialRuntime = read("js/tarinai_social_action_runtime.js");
const actionDefs = read("js/tarinai_action_definitions.js");
const family = read("js/world_family_social.js");
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
ok(/t\.reproductionTimer\s*=\s*\(Number\(t\.reproductionTimer/.test(creature), "reproduction cooldown advances in the outer per-frame creature pass");
ok(!/reproductionTimer\s*-=\s*dt/.test(frame), "full AI cadence no longer double-advances reproduction cooldown");
ok(/const candidateBudget = Math\.max\(20, scanLimit \+ 8\)/.test(social) && /if \(inspected >= candidateBudget\) break;/.test(social), "dense contact scans are bounded instead of walking the full local population");
ok(/const currentMateTarget =/.test(social) && /detailed\.push\(\{ o: currentMateTarget/.test(social), "the selected mate target is injected into bounded contact checks");
ok(/id: "population",\s*\n\s*title: "\\u4eba\\u53e3"/.test(achievements), "achievement population category is titled 人口");
ok(/socialParts\.mate \+= 28/.test(needs), "ready unpaired adults receive a mating sub-need above the start threshold");
ok(/if \(subNeed === "mate"\) \{[\s\S]*subValue >= threshold/.test(needs), "mating sub-need can start independently of aggregate loneliness");
ok(/!tarinai\.juvenile/.test(needs) && /!t\.juvenile/.test(actionDefs), "juveniles do not proactively start mating actions");
ok(/!t\.juvenile && !other\.juvenile/.test(socialRuntime), "contact mating excludes juveniles");
ok(/if \(a\.juvenile \|\| b\.juvenile\) return false;/.test(family), "birth ritual rejects juvenile participants centrally");
console.log("Reproduction regression audit passed.");

View file

@ -1,119 +0,0 @@
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const root = path.join(__dirname, "..");
const achievementSource = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
class ClassList { add() {} remove() {} contains() { return false; } toggle() { return false; } }
class Element {
constructor() { this.classList = new ClassList(); this.children = []; this.dataset = {}; this.style = { setProperty() {}, removeProperty() {} }; this.hidden = true; }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
addEventListener() {} focus() {} setAttribute() {}
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
get offsetWidth() { return 100; }
}
function harness() {
const storage = new Map();
const ids = ["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"];
const elements = new Map(ids.map(id => [id, new Element()]));
let timer = 0;
const c = {
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
location: { protocol: "file:", search: "" }, crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
localStorage: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, String(v)), removeItem: k => storage.delete(k) },
document: { hidden: false, getElementById: id => elements.get(id) || null, createElement: () => new Element(), addEventListener() {} },
TarinaiEvents: { emit() {} }, TarinaiGameDialogs: { confirm: async () => true }, showToast() {}, audio: { uiClick() {} },
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {}, fetch: async () => { throw new Error("offline"); },
TARINAI_VERSION: "39.16.77",
};
c.window = c; c.globalThis = c;
vm.createContext(c); vm.runInContext(achievementSource, c, { filename: "achievements.js" });
return { c, api: c.TarinaiAchievements };
}
function assert(value, message) { if (!value) throw new Error(message); }
(async function run() {
// Sauna timing must start on entering the hot state, not refresh every hot frame.
let h = harness();
let temperature = 40;
const t = { dead: false, x: 0, y: 0 };
const world = {
time: 0, tarinai: [t], ants: [], items: [],
feltTemperatureFor() { return temperature; },
temperatureStatusFor(v) { return { direction: v > 25 ? "hot" : (v < 10 ? "cold" : "comfort"), comfortable: v >= 10 && v <= 25 }; },
};
h.api.evaluateWorld(world, {});
world.time = 20; h.api.evaluateWorld(world, {}); // Still hot: must not restart the timer.
world.time = 20.1; temperature = 0; h.api.evaluateWorld(world, {});
assert(!h.api.isUnlocked("sauna_cold_plunge"), "sauna timer refreshed while continuously hot");
world.time = 30; temperature = 40; h.api.evaluateWorld(world, {}); // New hot entry.
world.time = 40; temperature = 0; h.api.evaluateWorld(world, {});
assert(h.api.isUnlocked("sauna_cold_plunge"), "sauna timer did not restart on a genuine new hot entry");
// Achievement reset must clear per-world/per-object hidden progress.
h = harness();
const tarinai = {
dead: false,
_achievementDirectFeedCount: 9,
_achievementDirectTreatmentCount: 3,
_achievementSaunaHotAt: 12,
_achievementSaunaWasHot: true,
_achievementHeldStartedAt: 1234,
};
const bomb = { type: "sticky_bomb", stickyBombPassCount: 14, _achievementPlayerPlaced: true, _achievementPlacedAt: 9 };
const resetWorld = { time: 100, tarinai: [tarinai], items: [bomb] };
h.c.world = resetWorld;
await h.api.reset();
assert(tarinai._achievementDirectFeedCount === 0 && tarinai._achievementDirectTreatmentCount === 0, "care progress survived achievement reset");
assert(tarinai._achievementSaunaHotAt == null && tarinai._achievementHeldStartedAt == null, "timed per-tarinai progress survived achievement reset");
assert(bomb.stickyBombPassCount === 0, "sticky-bomb relay progress survived achievement reset");
assert(bomb._achievementPlayerPlaced === true && bomb._achievementPlacedAt == null, "reset should preserve placement identity but clear quick-delete timing");
// World-changing history/ground operations are interventions, so passive observation must restart.
h = harness();
const observerWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
h.api.evaluateWorld(observerWorld, {});
observerWorld.time = 359.9; h.api.evaluateWorld(observerWorld, {});
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "observer unlocked before three game days");
h.api.recordHistoryAction("undo", { world: observerWorld, deadRestored: 0 });
observerWorld.time = 360; h.api.evaluateWorld(observerWorld, {});
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "undo did not reset passive-observation progress");
observerWorld.time = 500;
h.api.recordGroundChange({ world: observerWorld, previous: "soil", next: "ice", now: 1000 });
observerWorld.time = 859.9; h.api.evaluateWorld(observerWorld, {});
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "ground change did not reset passive-observation progress");
observerWorld.time = 860; h.api.evaluateWorld(observerWorld, {});
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three uninterrupted game days");
// Manually adding a Tarinai is also a gameplay intervention. This must reset
// the surveillance-camera window even if the separate lively-making achievement
// has already been completed.
h = harness();
const addWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
h.api.evaluateWorld(addWorld, {});
addWorld.time = 359.9;
h.api.recordManualTarinaiAdded({ world: addWorld, tarinai: { id: "manual-1" }, tool: "new" });
addWorld.time = 719.8; h.api.evaluateWorld(addWorld, {});
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "manual Tarinai addition did not reset passive-observation progress");
addWorld.time = 719.9; h.api.evaluateWorld(addWorld, {});
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three game days following manual Tarinai addition");
// Minimalist needs player-placement identity even after Unplanned City is already unlocked.
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
assert(snapshotSource.includes('const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;'), "minimalist placement metadata is not retained independently of quick-delete progress");
assert(snapshotSource.includes('needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0'), "snapshot does not serialize minimalist placement identity");
// Lethal shocks must count before damage can mark the target dead.
const signalSource = fs.readFileSync(path.join(root, "js", "signal_system.js"), "utf8");
const shockStart = signalSource.indexOf("function shockTarget");
const shockEnd = signalSource.indexOf("function damageFromWires", shockStart);
const shockBody = signalSource.slice(shockStart, shockEnd);
const recordAt = shockBody.indexOf("recordWireShock");
const damageAt = shockBody.indexOf("target.damage?.");
assert(recordAt >= 0 && damageAt >= 0 && recordAt < damageAt, "wire-shock achievement is still recorded after potentially lethal damage");
console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions including manual Tarinai addition, minimalist save metadata, and lethal wire shocks passed");
})();

View file

@ -1,129 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function createHarness({ owner = true, lethal = true, neverStopped = true, clearedOwner = true } = {}) {
let unlocks = 0;
let damageCalls = 0;
const ownerId = "t-owner";
const tarinai = {
id: owner ? ownerId : "t-other",
releasedLiveId: "",
dead: false,
x: 100,
y: 100,
radius: 20,
vx: 0,
vy: 0,
fallTimer: 0,
fallMax: 0,
enterPanic() {},
};
const item = {
type: "zunchi",
dead: false,
x: 100,
y: 100,
prevX: 100,
prevY: 100,
r: 14,
amount: 240,
vx: 200,
vy: 0,
achievementBurstOwnerId: ownerId,
achievementBurstNeverStopped: neverStopped,
achievementBurstClearedOwner: clearedOwner,
};
const world = {
time: 10,
w: 1000,
h: 1000,
tarinai: [tarinai],
drawListDirty: false,
liveTarinaiById(id) { return tarinai.id === id && !tarinai.dead ? tarinai : null; },
nearbyTarinai() { return [tarinai]; },
isTarinaiHiddenInNestBox() { return false; },
applyImpactDamage(target) {
damageCalls += 1;
if (!lethal) return true;
target.dead = true;
target.releasedLiveId = target.id;
target.id = "";
return true;
},
spawnFallEffect() {},
effects: [],
};
const context = {
console,
globalThis: null,
window: undefined,
CONFIG: { worldPadding: 30 },
clamp: (value, min, max) => Math.max(min, Math.min(max, value)),
distXY: (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by),
rand: () => 0,
Effect: function Effect() {},
TarinaiPhysics: { applyKinematicItemMotion() {} },
TarinaiItemDynamicBallSystem: { resolveObstacleCollisions() {} },
TarinaiAchievements: {
recordSelfZunchiDeath() { unlocks += 1; },
},
};
context.globalThis = context;
vm.createContext(context);
const source = fs.readFileSync(path.join(__dirname, "..", "js", "item_dynamic_zunchi_system.js"), "utf8");
vm.runInContext(source, context, { filename: "item_dynamic_zunchi_system.js" });
return { context, world, tarinai, item, get unlocks() { return unlocks; }, get damageCalls() { return damageCalls; } };
}
function runOne(options = {}) {
const h = createHarness(options);
h.context.TarinaiItemDynamicZunchiSystem.updateMotion(h.item, 0.016, h.world);
return h;
}
let result = runOne({ owner: true, lethal: true, neverStopped: true, clearedOwner: true });
assert(result.unlocks === 1, "fatal owner return collision should unlock even after live ID release");
result = runOne({ owner: true, lethal: false, neverStopped: true, clearedOwner: true });
assert(result.unlocks === 0, "nonfatal owner collision must not unlock");
result = runOne({ owner: false, lethal: true, neverStopped: true, clearedOwner: true });
assert(result.unlocks === 0, "fatal collision with another tarinai must not unlock");
result = runOne({ owner: true, lethal: true, neverStopped: false, clearedOwner: true });
assert(result.unlocks === 0, "stopped or player-held burst zunchi must remain disqualified");
// Exact route prerequisite: the emitted zunchi begins inside its owner, but that
// spawn overlap must neither damage the owner nor consume the later return hit.
result = runOne({ owner: true, lethal: true, neverStopped: true, clearedOwner: false });
assert(result.unlocks === 0, "spawn overlap must not unlock");
assert(result.damageCalls === 0, "spawn overlap must not damage or slow the owner collision path");
assert(result.item.achievementBurstClearedOwner === false, "spawn overlap must remain uncleared while still inside owner");
// Move outside once (the route's outbound leg / bounce-fence approach), then
// return at lethal speed. This must unlock.
result = createHarness({ owner: true, lethal: true, neverStopped: true, clearedOwner: false });
result.item.x = 180;
result.item.prevX = 100;
result.item.vx = 320;
result.context.TarinaiItemDynamicZunchiSystem.updateMotion(result.item, 0.016, result.world);
assert(result.item.achievementBurstClearedOwner === true, "burst zunchi must arm after clearing its owner");
assert(result.damageCalls === 0, "outbound leg must not damage the owner");
result.item.x = 100;
result.item.prevX = 180;
result.item.vx = -900; // representative accelerated return after a bounce fence
result.item.vy = 0;
result.world.time += 0.2;
result.context.TarinaiItemDynamicZunchiSystem.updateMotion(result.item, 0.016, result.world);
assert(result.unlocks === 1, "cleared, accelerated return collision with owner must unlock when fatal");
console.log("[OK] self-zunchi spawn-clear-return regression cases passed");

View file

@ -1,23 +0,0 @@
"use strict";
const fs = require("fs");
const moveSrc = fs.readFileSync("js/tarinai_social_move_life.js", "utf8");
const needSrc = fs.readFileSync("js/tarinai_needs_items.js", "utf8");
const actionSrc = fs.readFileSync("js/tarinai_action_definitions.js", "utf8");
const colonySrc = fs.readFileSync("js/colony_situation_system.js", "utf8");
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
ok(!moveSrc.includes('this.socialCrowdSample = {'), "contact scan no longer records crowd density");
ok(!needSrc.includes('const crowdSample = tarinai.socialCrowdSample'), "relation need no longer consumes crowd pressure");
ok(!actionSrc.includes('createCrowdEscapeActionSpec(),'), "crowd retreat action is not registered");
ok(!colonySrc.includes('nearestAverage('), "colony status no longer performs O(N^2) crowd distance scans");
ok(!colonySrc.includes('add("overcrowded"'), "overcrowded colony status is no longer selected");
ok(moveSrc.includes('const candidateBudget = Math.max(20, scanLimit + 8)'), "dense contact work has a fixed candidate budget");
ok(moveSrc.includes('if (inspected >= candidateBudget) break'), "dense contact scans stop before becoming population-squared work");
ok(moveSrc.includes('const currentMateTarget =') && moveSrc.includes('detailed.push({ o: currentMateTarget'), "selected mating partners bypass contact candidate truncation");
ok(moveSrc.includes('if (pos < scanLimit)'), "contact scan keeps a bounded nearest-candidate list");
ok(moveSrc.includes('if (detailed.length > scanLimit) detailed.pop()'), "nearest-candidate list remains bounded under dense populations");
ok(moveSrc.includes('const pairLeader = String(this.id) < String(o.id)'), "symmetric pair work has one deterministic leader");
ok(moveSrc.includes('const d2 = dx * dx + dy * dy'), "distance-squared prefilter is used");
ok(moveSrc.includes('const selfProfile = this.personalityProfile()'), "self personality profile is cached per scan");
ok(!moveSrc.includes('open.sort('), "social interaction does not introduce full sorting");
ok(moveSrc.includes('const combinedStartChance = 1 - (1 - oneDirectionChance) * (1 - oneDirectionChance)'), "single-pass pair processing preserves old start probability");
if (process.exitCode) process.exit(process.exitCode);

View file

@ -1,23 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = file => fs.readFileSync(path.join(root, file), "utf8");
let failed = 0;
function ok(cond, msg) { if (!cond) { failed++; console.error(`FAIL: ${msg}`); } else console.log(`OK: ${msg}`); }
const family = read("js/world_family_social.js");
const tools = read("js/world_tool_actions.js");
const colony = read("js/colony_situation_system.js");
const placement = read("js/world_placement_log.js");
const bind = read("js/ui_bind.js");
ok(family.includes('ITEM_TRAITS?.sleepFurniture') && family.includes('["bed", "nest_box", "pipe", "grass_bed"]'), "world object tooltips are restricted to sleep furniture");
ok(tools.includes('TarinaiItemDynamicPinSystem?.detach?.(target, this, "pinch")'), "delete tool detaches lodged pins with pinch-equivalent effects before deletion");
ok(!tools.includes('刺さった鋲は、つまんで抜いてください'), "lodged pin delete rejection was removed");
ok(colony.includes('confusionPanicThreshold') && colony.includes('Math.ceil(m.n * 0.32)'), "confusion threshold was raised");
ok(colony.includes('breedingThreshold') && colony.includes('Math.ceil(m.n * 0.18)') && !colony.includes('lastBirthAt || -999) <= 45'), "breeding state requires stronger active participation and no longer triggers from one recent birth");
ok(placement.includes('this._scrollSelectedDataTopOnNextSelection = Boolean(found)') && placement.includes('selectionResult?.changed === false'), "clicking a Tarinai requests selected-data scrolling even when reselecting the same actor");
ok(bind.includes('window.scrollToSelectedDataPanel = () =>') && bind.includes('scrollPanelToTab("selected")'), "selected-data panel exposes a direct scroll target helper");
if (failed) process.exit(1);

View file

@ -1,85 +0,0 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def text(rel):
return (ROOT / rel).read_text(encoding='utf-8')
def must(cond, msg):
if not cond:
raise AssertionError(msg)
index = text('index.html')
ui_bind = text('js/ui_bind.js')
ui_mouse = text('js/ui_input_mouse.js')
ui_shared = text('js/ui_input_shared.js')
view = text('js/world_view.js')
data = text('js/data.js')
temp = text('js/world_temperature_system.js')
disease = text('js/tarinai_item_effects.js')
spatial = text('js/sim_core.js')
spatial_budget = text('js/world_spatial_budget.js')
targeting = text('js/tarinai_item_targeting.js')
ach = text('js/achievements.js')
# Tool mode survives ordinary controls, but blank UI space explicitly clears it.
must('isInteractiveUiTarget' in ui_bind and 'clearSelectedToolSilently' in ui_bind,
'blank-UI tool cancellation helpers are missing')
must('!canvas?.contains?.(target) && !isInteractiveUiTarget(target)' in ui_bind,
'blank UI space is not the exclusive document-click cancellation path')
must('target.closest("button, a, input, select, textarea, label, summary' in ui_bind,
'ordinary UI controls are not protected from tool cancellation')
# Limits: 300, next slot 301 = infinity, default infinity.
must('COLONY_LIMIT_MAX = 300' in ui_bind, 'colony limit max is not 300')
must('COLONY_LIMIT_INFINITY_SLOT = 301' in ui_bind, 'infinity slot is not 301')
must(re.search(r'max="301"[^>]*value="301"', index) is not None, 'limit slider does not default to infinity slot')
# Right-drag context-menu suppression.
must('suppressContextMenuUntil' in ui_mouse and 'contextmenu' in ui_mouse, 'right-pan context-menu suppression missing')
must('suppressContextMenuUntil' in ui_shared, 'right-pan suppression timestamp is not set on pan end')
# Zoom.
must(re.search(r'BASE_ZOOM_MAX\s*=\s*4\.5', view) is not None, 'zoom maximum is not 4.5')
# Disease probability reduced to one-third at the shared helper.
must(re.search(r'Number\(base\)[^\n]*/\s*3', disease) is not None or '/ 3' in disease[disease.find('diseaseChance'):disease.find('diseaseChance')+240], 'diseaseChance is not reduced to one-third')
# Summer correction removed completely.
must(re.search(r'summerTemperatureBoost\s*:\s*0\b', data) is not None, 'summerTemperatureBoost is not zero')
body = re.search(r'(?:function\s+)?seasonTemperatureBias\s*\([^)]*\)\s*\{([\s\S]*?)\n\s*\}', temp)
must(body is not None and re.search(r'\breturn\s+0\s*;', body.group(1)), 'seasonTemperatureBias does not return zero')
# Grass: dedicated spatial index and no global grass fallback re-add in food search.
must('grassCells = new Map()' in spatial, 'dedicated grassCells index missing')
must('nearbyGrass(' in spatial_budget and 'spatial.grassCells' in spatial_budget, 'nearbyGrass spatial query missing')
must('type === "grass"' in targeting and 'nearbyFood' in targeting, 'grass global-bucket skip guard missing')
# Ensure the hot-path global type bucket explicitly skips grass when nearbyFood exists.
must(re.search(r'if\s*\(\s*type\s*===\s*["\']grass["\'][^)]*nearbyFood', targeting) is not None,
'food targeting does not skip the global grass bucket')
# Achievements 73-75 and persistent Memento Mori progress.
for aid in ('well_informed', 'lively_making', 'memento_mori'):
must(aid in ach, f'achievement {aid} missing')
must('memento_mori' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach, 'Memento Mori pre-unlock condition visibility missing')
must('totalDeathCount' in ach, 'persistent total death counter missing')
# Current UI/achievement/colony changes.
placement_preview = text('js/placement_preview_system.js')
placement_log = text('js/world_placement_log.js')
colony = text('js/colony_situation_system.js')
components = text('css/components.css')
must('achievementToastCondition' in index and 'achievement-toast-condition' in components, 'achievement unlock toast condition line missing')
must('if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";' in ach, 'achievement toast does not render the completion condition')
must('lively_making' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach and 'manualTarinaiAddedCount' in ach, 'Lively Making persistent/pre-unlock progress is missing')
must(r'title: "\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization missing')
must('num(rect.bottom) > (worldRef.h || 0)' in placement_preview, 'placement preview still reserves an artificial bottom padding')
must('rect.bottom > this.h' in placement_log, 'confirmed placement still reserves an artificial bottom padding')
must('Math.max(5, Math.ceil(m.n * 0.10))' in colony, 'crisis mortality threshold is still too loose')
must('criticalRatio' in colony and 'crisisMortalitySignal && crisisSystemicStress' in colony and 'crisisCollapseSignal' in colony, 'crisis no longer requires compound severe conditions')
must('!m.populationIncreasing' in colony, 'crisis population-growth exclusion was lost')
print('[OK] UI/limits/input/zoom/disease/grass/summer/achievement/placement/crisis regression audit passed')

View file

@ -1,11 +1,115 @@
"use strict";
const APP_VERSION = "39.16.137";
const APP_VERSION = "39.18.3";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
// Static runtime list synchronized with app_manifest.json for this release.
const coreScriptNames = [
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "circuit_board_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_environment_hazard_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "memorial_bell_system", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "seesaw_system", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "game_dialogs", "achievements", "ui_helpers", "ui_log", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "circuit_board_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_environment_hazard_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "memorial_bell_system", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "seesaw_system", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "game_dialogs", "achievement_catalog", "achievements", "ui_helpers", "ui_log", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
];
const mediaAssetNames = [
"assets/objects/ant_queen.webp",
"assets/objects/ant_worker.webp",
"assets/objects/genkotsu.webp",
"assets/objects/oshibyo.webp",
"assets/objects/oshibyo_stuck.webp",
"assets/objects/plushie_bear.png",
"assets/objects/pushpin.webp",
"assets/objects/pushpin_stuck.webp",
"assets/objects/zunchi.webp",
"assets/objects/zunchi_02.webp",
"assets/sounds/achievement_unlock.mp3",
"assets/sounds/bicycle_bell.mp3",
"assets/sounds/firecracker_explosion.mp3",
"assets/sounds/major_damage_01.wav",
"assets/sounds/major_damage_02.wav",
"assets/sounds/major_damage_03.wav",
"assets/sounds/major_damage_04.wav",
"assets/sounds/shoot_bolt_action.mp3",
"assets/sounds/shoot_pistol.mp3",
"assets/sounds/voice_001_hau.wav",
"assets/sounds/voice_002_flee.wav",
"assets/sounds/voice_003_po.wav",
"assets/sounds/voice_004_eat.wav",
"assets/sounds/voice_005_stress.wav",
"assets/sounds/voice_006_sleep.wav",
"assets/sounds/voice_007_sunbath_pokapoka.wav",
"assets/sounds/voice_008_temperature_buruburu.wav",
"assets/sounds/voice_009_temperature_achui.wav",
"assets/sprites/tarinai_01_smile.webp",
"assets/sprites/tarinai_02_angry.webp",
"assets/sprites/tarinai_03_teary.webp",
"assets/sprites/tarinai_04_jito.webp",
"assets/sprites/tarinai_05_drool.webp",
"assets/sprites/tarinai_06_cry.webp",
"assets/sprites/tarinai_07_sleep.webp",
"assets/sprites/tarinai_08_pokan.webp",
"assets/sprites/tarinai_09_stretch.webp",
"assets/sprites/tarinai_10_hurt.webp",
"assets/sprites/tarinai_11_weak.webp",
"assets/sprites/tarinai_12_back.webp",
"assets/sprites/tarinai_13_flee.webp",
"assets/sprites/tarinai_14_zunda_eat.webp",
"assets/sprites/tarinai_15_hurt2.webp",
"assets/sprites/tarinai_16_normal_smirk.webp",
"assets/sprites/tarinai_17_normal_tongue.webp",
"assets/sprites/tarinai_18_fear.webp",
"assets/sprites/tarinai_19_flee_fear2.webp",
"assets/sprites/tarinai_20_sleep2.webp",
"assets/sprites/tarinai_21_normal_happy.webp",
"assets/sprites/tarinai_22_fear_blue.webp",
"assets/sprites/tarinai_23_fear_cry.webp",
"assets/sprites/tarinai_24_stress_dizzy.webp",
"assets/sprites/tarinai_25_stress_sweat.webp",
"assets/sprites/tarinai_26_intimidate.webp",
"assets/sprites/tarinai_27_birth_ritual.webp",
"assets/sprites/tarinai_28_zunchi_slave.webp",
"assets/sprites/tarinai_29_hungry_70.webp",
"assets/sprites/tarinai_30_zunchi_slave_alt.webp",
"assets/sprites/tarinai_31_sunbath_01.webp",
"assets/sprites/tarinai_32_sunbath_02.webp",
"assets/sprites/tarinai_33_sunbath_03.webp",
"assets/sprites/tarinai_34_hot_01.webp",
"assets/sprites/tarinai_35_hot_02.webp",
"assets/sprites/tarinai_36_hot_03.webp",
"assets/sprites/tarinai_37_cold_01.webp",
"assets/sprites/tarinai_38_cold_02.webp",
"assets/sprites/tarinai_39_cold_03.webp",
"assets/ui/apple-touch-icon.png",
"assets/ui/ecology_hp_stress.webp",
"assets/ui/favicon.png",
"assets/ui/tool_ammo.webp",
"assets/ui/tool_ant_nest.webp",
"assets/ui/tool_ball.webp",
"assets/ui/tool_bed.webp",
"assets/ui/tool_delete.webp",
"assets/ui/tool_duplicator.webp",
"assets/ui/tool_dwarf_drug.webp",
"assets/ui/tool_fence_h.webp",
"assets/ui/tool_fence_v.webp",
"assets/ui/tool_fight_mochi.webp",
"assets/ui/tool_firecracker.webp",
"assets/ui/tool_genkotsu.webp",
"assets/ui/tool_giant_drug.webp",
"assets/ui/tool_grass.webp",
"assets/ui/tool_laxative.webp",
"assets/ui/tool_love_mochi.webp",
"assets/ui/tool_mercury.webp",
"assets/ui/tool_mystery_drug.webp",
"assets/ui/tool_nest_box.webp",
"assets/ui/tool_new.webp",
"assets/ui/tool_niteropu.webp",
"assets/ui/tool_observe.webp",
"assets/ui/tool_oshibyo.webp",
"assets/ui/tool_protein.webp",
"assets/ui/tool_pushpin.webp",
"assets/ui/tool_shoot.png",
"assets/ui/tool_signboard.webp",
"assets/ui/tool_sleep_drug.webp",
"assets/ui/tool_stone.webp",
"assets/ui/tool_sweet.webp",
"assets/ui/tool_zunchi.webp",
"assets/ui/tool_zunda_juice.webp"
];
const CORE_ASSETS = [
"./",
@ -16,49 +120,68 @@ const CORE_ASSETS = [
`./css/components.css?${v}`,
`./css/mobile.css?${v}`,
...coreScriptNames.map(name => `./js/${name}.js?${v}`),
"./app_manifest.json",
"./manifest.webmanifest",
"./favicon.ico",
"./assets/ui/pwa-icon-192.png",
"./assets/ui/pwa-icon-512.png",
...mediaAssetNames.map(name => `./${name}`),
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined));
self.skipWaiting();
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(CORE_ASSETS);
await self.skipWaiting();
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));
self.clients.claim();
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter(key => key.startsWith("tarinai-colony-") && key !== CACHE_NAME)
.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
function isStaticAsset(request) {
const url = new URL(request.url);
return /\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname);
return /\.(?:js|css|json|webmanifest|ico|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(new URL(request.url).pathname);
}
function cacheableResponse(response) {
return response && response.status === 200 && response.type !== "opaque";
}
function putCacheSafely(request, response) {
if (!cacheableResponse(response)) return Promise.resolve(false);
return caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())).then(() => true).catch(() => false);
}
self.addEventListener("fetch", (event) => {
self.addEventListener("fetch", event => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (/service-worker\.js$/i.test(url.pathname)) return;
// PWA-04 intentionally remains unchanged: cross-origin Google Fonts are not cached.
if (url.origin !== self.location.origin || /service-worker\.js$/i.test(url.pathname)) return;
if (request.mode === "navigate" || /index\.html$/i.test(url.pathname)) {
event.respondWith(fetch(request).then(response => {
putCacheSafely(request, response);
return response;
}).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));
const networkResponse = fetch(request);
const cacheWrite = networkResponse.then(response => putCacheSafely(request, response)).catch(() => false);
event.waitUntil(cacheWrite.then(() => undefined));
event.respondWith(networkResponse.catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));
return;
}
if (isStaticAsset(request)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {
putCacheSafely(request, response);
const responsePromise = caches.match(request).then(async cached => {
if (cached) return cached;
// Media are precached at their canonical URL. Match versioned and unversioned
// requests to the same entry so dynamically requested assets remain offline-safe.
const canonical = await caches.match(request, { ignoreSearch: true });
if (canonical) return canonical;
const response = await fetch(request);
await putCacheSafely(request, response);
return response;
})));
});
event.waitUntil(responsePromise.then(() => undefined, () => undefined));
event.respondWith(responsePromise);
}
});

6
start_server.bat Normal file
View file

@ -0,0 +1,6 @@
@echo off
setlocal
set PORT=%~1
if "%PORT%"=="" set PORT=8000
cd /d "%~dp0"
php -S 127.0.0.1:%PORT% -t "%~dp0" "%~dp0router.php"

5
start_server.sh Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -eu
PORT="${1:-8000}"
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec php -S "127.0.0.1:${PORT}" -t "$ROOT" "$ROOT/router.php"