diff --git a/.achievement_data/.gitkeep b/.achievement_data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.achievement_data/.htaccess b/.achievement_data/.htaccess new file mode 100644 index 0000000..fa39801 --- /dev/null +++ b/.achievement_data/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/ACHIEVEMENT_AUDIT.md b/ACHIEVEMENT_AUDIT.md new file mode 100644 index 0000000..a14ea76 --- /dev/null +++ b/ACHIEVEMENT_AUDIT.md @@ -0,0 +1,186 @@ +# 実績システム詳細監査レポート + +対象: `tarinai - コピー(8).zip` +監査対象: 全64実績、解除イベント、進捗リセット、保存・復元、サーバー同期、関連する衝突・死亡処理 + +## 1. 修正した不具合 + +### 「あ^~たまらねぇぜ」(`self_zunchi_death`) + +**確定仕様** + +- おしり鋲を抜いてずんちを排出しただけでは解除しない。 +- 排出ずんちが一度本人の当たり判定外まで離れた後、本人へ戻って衝突する必要がある。 +- 本人に戻って衝突しても、非致死なら解除しない。 +- 本人への帰還衝突で本人が死亡した場合のみ解除する。 +- プレイヤーが保持した、または一度停止した排出ずんちは対象外。 +- バウンス柵などで反射・加速したこと自体は失格条件ではない。 + +**今回確認した実原因** + +おしり鋲から排出されるずんちは本人の中心付近(±8px)に生成されます。一方、旧判定は生成直後から本人との高速衝突を有効としていたため、まだ外へ飛び出す前の重なりで全弾が自己衝突扱いになり得ました。この初動衝突はダメージ、速度の18%化、同一個体への0.55秒ヒットクールダウンを発生させるため、 + +`排出 → バウンス柵で反射・加速 → 本人へ帰還して致死衝突` + +という本来の達成経路を阻害していました。 + +また、以前確認した別の不具合として、致死衝突時は死亡処理内で生存個体ID `t.id` が解放されるため、ダメージ後に所有者IDを比較すると正しい本人死亡でも一致判定を失う問題がありました。 + +**修正** + +- 排出ずんちに `achievementBurstClearedOwner` を追加し、生成時は未成立に設定。 +- 本人の当たり判定外まで一度離れた時点で帰還判定を有効化。 +- それ以前の生成直後オーバーラップは、ダメージ・減速・ヒットクールダウンを含めて完全に無視。 +- 帰還後の致死衝突では、ダメージ適用前に所有者一致を保持して、死亡処理によるID解放後も解除できるよう維持。 +- バウンス柵による反射・加速では資格を失わない。 + +追加回帰テストでは以下を確認済みです。 + +1. 生成直後の本人との重なり → ダメージなし、解除なし。 +2. 本人から一度離れる → 帰還判定が有効化。 +3. バウンス柵相当の高速帰還で本人へ致死衝突 → 解除する。 +4. 帰還しても非致死 → 解除しない。 +5. 他個体への致死衝突 → 解除しない。 +6. 停止・保持済みの排出ずんち → 解除しない。 + +### 「敵の敵は味方」(`enemy_enemy_friend`) + +**原因** + +30体以上の状態で連続数を稼いだあと、個体数が30未満へ落ちても、その間にアリを倒さなければ旧連続数が残っていました。その後30体へ戻すと、過去の連続数から再開できました。 + +**修正** + +死亡処理の時点で生存数を再計算し、30体未満になった瞬間に連続数を0へ戻すよう変更しました。フィールド進捗リセット時には、同一Worldオブジェクトに残り得るランタイム状態と危険アイテム戦闘追跡も破棄するようにしました。 + +--- + +## 2. 全64実績の実装条件 + +### 生態・繁殖系 + +| # | 実績 | 実装上の解除条件 | +|---|---|---| +| 1 | はじめての繁殖 | 繁殖成功イベントが1回発生。 | +| 2 | ずんちどれい | 自然な戦績判定による変身。戦闘5回以上、勝率30%以下などの条件を満たし、戦績経路で奴隷化に成功。単なる生成・手動付与は対象外。 | +| 3 | たりない王 | 自然な戦績判定による変身。戦闘6回以上、勝率75%以上などを満たし、戦績経路で王化に成功。 | +| 4 | 5世代のずんちどれい | 同一観察世界で、自然発生した奴隷の「世代番号」に5世代連続の並びが成立。直系血統限定ではない。 | +| 5 | 3世代のたりない王 | 同一観察世界で、自然発生した王の世代番号に3世代連続の並びが成立。 | +| 6 | たりない不在のアリの巣 | 生きたアリの巣が1個以上あり、生存たりないが0体。 | +| 7 | ベビーブーム | 同一World内の60ゲーム秒のローリング区間で繁殖30回。内部IDには旧閾値 `50` が残るが、表示と実判定は30で一致。 | +| 8 | 偉大なる母 | 累計繁殖成功3333回。内部IDには旧閾値 `1000` が残る。進捗は永続。 | +| 9 | 天寿 | 死因が「天寿を全うした」と正規化される死亡。 | +| 10 | 25体全員が病気 | 生存25体以上で、全員がずんち病・爆発病・喧嘩病のいずれかを持つ。睡眠病だけの個体は不適格。 | +| 11 | 幸せなコロニー | 6日目以降、コロニー気分 `happy` をゲーム内1日分連続維持。 | +| 12 | 直接給餌33回 | 現在の観察世界で、成功した直接の食べ物プレゼントを33回。 | +| 13 | 25体を5分守る | 生存25体以上を300ゲーム秒維持。天寿以外の死亡でタイマー再開始。天寿でも結果として25体未満になれば条件未達へ戻る。 | +| 14 | アリ25匹 | 生きたアリが同時に25匹以上。 | +| 15 | 過保護 | 同一個体に直接食料10回以上、かつ直接の薬・治療3回以上。 | +| 16 | 自給自足 | 生存20体以上、複製機0個を600ゲーム秒維持。直接給餌するとタイマーをリセット。 | +| 17 | 雨宿り | 天候が小雨、生存20体以上、全生存個体が雨宿り判定。 | +| 18 | 永遠の歴史 | 10世代以上の誕生、または世界の最大世代が10以上。 | +| 19 | 町医者 | 実際にHPを1以上回復した応急手当を累計50回。 | +| 20 | 繁殖を求めし者 | ラブ餅影響下の成功繁殖を累計721回。 | + +### 実験・危険行動系 + +| # | 実績 | 実装上の解除条件 | +|---|---|---| +| 21 | あ^~たまらねぇぜ | おしり鋲由来の排出ずんちが一度本人から離れ、停止・保持されないまま本人へ帰還して衝突し、その衝突ダメージで本人が死亡。バウンス柵による反射・加速は有効。今回再修正。 | +| 22 | 10秒で50死亡 | 同一World内の10ゲーム秒ローリング区間で死亡50件。 | +| 23 | サッカーボール死 | 通常の `ball` が速度285以上で衝突し、その衝突ダメージが致死。風船は対象外。 | +| 24 | 戦闘中の危険アイテム連鎖死 | 戦闘ペアの一方が危険カテゴリ由来の新しいダメージで死亡し、その相手も後に危険カテゴリ由来で死亡。現在、同一World内で第1死亡から第2死亡までの明示的な時間制限はない。 | +| 25 | ヘコヘコ中に着火 | 繁殖儀式状態の個体へ着火。着火による状態変更前の儀式状態を参照。 | +| 26 | 下剤中の餓死 | 下剤効果が有効なフレームで、飢餓・空腹系の死因により死亡。 | +| 27 | アリ100匹討伐 | アリ死亡を累計100件。永続進捗。 | +| 28 | 絶対零度未満 | 生きたアイテム位置の温度計算値が `-273.15℃` 未満。温度効果は重複加算されるため到達可能。 | +| 29 | サウナ→水風呂 | 同一生存個体が快適域外の高温状態に入り、15ゲーム秒以内に快適域外の低温状態へ移行。 | +| 30 | 薬品台帳コンプリート | 応急手当、鎮静剤、睡眠薬、下剤、プロテイン、ニテロプ、弾薬、謎の薬、水銀、巨大化薬、小型化薬の11種を各1回使用。鎖と王冠は対象外。 | +| 31 | 水銀で天寿 | 水銀寿命モードが有効な個体が天寿で死亡。 | +| 32 | 敵の敵は味方 | 生存30体以上の状態で、危険カテゴリのアイテムを直近ダメージ源とするアリ死亡を10連続。危険アイテムでたりないを傷つけると連続数リセット。30体未満になった時点でも今回リセットするよう修正。 | +| 33 | 革命 | 王が喧嘩死し、直前0.1ゲーム秒以内の戦闘加害者がずんちどれい。 | +| 34 | 火に油 | 喧嘩餅を受けた個体が30ゲーム秒以内に参加する喧嘩が開始。 | +| 35 | 王の完全満足 | 生存中の王がプロテイン状態と巨大化薬状態を同時に持つ。 | +| 36 | 奴隷の完全満足 | 生存中の奴隷がニテロプ状態と小型化薬状態を同時に持つ。 | +| 37 | 混沌を求めし者 | 喧嘩餅効果中の個体を少なくとも一方に含む喧嘩開始を累計666回。 | +| 38 | 粘着爆弾15渡し | 同一の生きた粘着爆弾の受け渡し回数が15回。保存対象。 | +| 39 | 電線で7体感電 | 同一の通常電線 `wire` が7体の異なる生存たりないを感電させる。絶縁電線は対象外。 | + +### 建築・環境系 + +| # | 実績 | 実装上の解除条件 | +|---|---|---| +| 40 | 100個設置 | プレイヤーによる配置を累計100回。永続進捗。 | +| 41 | 機械化産業 | 永続進捗上でロープ・棒・バネを各1回以上配置し、信号駆動先がONになった履歴も成立。現実装は「現在同時に存在」ではなく履歴ANDで、信号源も検知器だけに厳密限定されていない。 | +| 42 | 情報化産業 | 電線または絶縁電線が、回路基板Aの出力端子と回路基板Bの入力端子を直接接続し、A出力とB入力がともにON。 | +| 43 | ロボット掃除 | ロボット掃除機が有効対象を200回処理。内部IDには旧閾値 `100` が残るが表示・実判定は200。 | +| 44 | ミニマリスト | 6日目以降、生存たりない1体以上、気分 `happy`、現在存在するプレイヤー設置物が10個以下。 | +| 45 | 無計画都市 | プレイヤー設置物を設置後30ゲーム秒以内に削除する行為を累計30回。 | +| 46 | 潔癖ロボット世界 | 生存アイテムがすべてロボット掃除機、生存たりない0、生存アリ0。さらに少なくとも1台が全6対象マスク、高速モード、稼働中。複数台可。 | +| 47 | メガロポリス | 生存たりない100体以上、複製機10個以上、草ベッド・巣箱・パイプ合計15個以上。 | +| 48 | トイレ5個 | 生きたトイレが5個以上。 | +| 49 | 公園の地面変更 | フィールド種別が `park` の状態で、有効な地面変更を1回。 | +| 50 | 1秒で地面4変更 | 壁時計の1秒ローリング区間で有効な地面変更4回。 | + +### 操作・プレイ継続系 + +| # | 実績 | 実装上の解除条件 | +|---|---|---| +| 51 | 5分間観察 | 最後の介入操作から300ゲーム秒。配置・削除・直接給餌・つつく・掴む・射撃・リンク等は介入として記録。閲覧だけは介入にならない。 | +| 52 | 9スロット保存 | 9個すべてのセーブスロットに保存ハッシュが存在。 | +| 53 | 1秒で4空クリック | 空フィールドクリックを壁時計1秒以内に4回。内部IDには旧名称 `pause_spam` が残る。 | +| 54 | Undo大量蘇生 | 1回のUndoで死亡個体数が10体以上減る。 | +| 55 | 30秒保持 | 同一の生存たりないを30,000ms連続してプレイヤー保持。タブ非表示で保持タイマーをリセット。 | +| 56 | Undo 20回 | 成功したUndoを累計20回。永続進捗。 | +| 57 | Redo 20回 | 成功したRedoを累計20回。永続進捗。 | +| 58 | ぬいぐるみを飛ばす | つつく操作の共通ターゲット処理からぬいぐるみを飛ばす。 | +| 59 | スナイパー | 発砲を累計333回。命中不要。 | +| 60 | 一桁FPS | 計測FPSが0より大きく10未満。非表示タブ由来の大きなフレーム間隔は計測から除外。 | +| 61 | 7日連続起動 | ローカル暦の日付で7日連続してゲームを開く。永続進捗。 | +| 62 | 24時間連続プレイ | 実績システム読込時点から壁時計で24時間経過。タブ非表示中も経過時間に含まれる。 | +| 63 | 1時間連続プレイ | 同様に壁時計で1時間経過。 | +| 64 | 真のたりない観察者 | 自身を除く63実績がすべて解除済み。 | + +--- + +## 3. 監査で残した仕様上の注意点 + +以下はコード上の不達成バグではありませんが、表示文から期待される意味と実装の範囲に差があります。条件変更はゲームデザイン判断になるため、今回は勝手に変更していません。 + +### 戦闘ペア危険死 + +最初の危険死で相手を記録したあと、同一World内では期限がありません。その相手がかなり後になって別の危険アイテムで死亡しても成立し得ます。フィールド進捗リセット時の追跡残留は今回除去しました。 + +### 機械化産業 + +現在は「ロープ・棒・バネを過去に各1回以上配置した」という永続履歴と、「信号駆動先がONになった履歴」のANDです。現在同じ設備として存在する必要はありません。また信号系は回路基板出力も信号源になり得るため、表示文を「検知器の信号」に厳密限定して読む場合は条件が広めです。 + +### 敵の敵は味方の「無傷」 + +現在のリセット対象は、危険カテゴリのアイテムによるたりないへのダメージです。通常の非危険ダメージまで含む「一切のダメージなし」ではありません。今回、30体未満への人口低下で連続数が残る明確な不具合のみ修正しました。 + +### 互換性のため残っている旧内部ID + +以下は内部ID名と現行閾値が異なりますが、表示文と実際の解除条件は一致しています。ID変更は既存保存データ・サーバー集計との互換性を壊すため変更していません。 + +- `birth_50_in_60_seconds` → 現行は30回 +- `great_mother_1000_births` → 現行は3333回 +- `robot_cleaner_100` → 現行は200回 +- `pause_spam_4_in_1_second` → 現行条件は空フィールドクリック + +--- + +## 4. 検証結果 + +以下を修正版に対して実行し、すべて成功しました。 + +- JavaScript全ファイルの `node --check` +- Python監査スクリプトのコンパイル確認 +- `achievement_api.php` のPHP構文確認 +- 全64実績の定義・UI・同期監査 +- プレイスタイル実績の境界値・リセット範囲・永続化監査 +- 実績イベント統合監査 +- サーバー状態・旧形式移行・並行同期監査 +- 「あ^~たまらねぇぜ」専用回帰テスト6ケース(生成直後除外・離脱後帰還を含む) + +最終テスト結果はいずれも `[OK]` です。 diff --git a/AI_MAP.md b/AI_MAP.md index bab0e6e..1f3a3c0 100644 --- a/AI_MAP.md +++ b/AI_MAP.md @@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th - 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. -- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.74`. +- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.75`. - 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`. @@ -84,7 +84,7 @@ This section records current boundaries only. Version-by-version cleanup history ## Current Feature Routing -- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.74`. +- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.75`. - 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. diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 3bc1dba..63a11d4 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -6,7 +6,7 @@ tarinai_compact_save_stage4 - Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte. - 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map. - Binary schema 46 is the only accepted save/spell format; it stores the current achievement mask and sticky-bomb pass count. -- Version: 39.16.74 +- Version: 39.16.75 - CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls. - Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected. @@ -177,3 +177,10 @@ tarinai_compact_save_stage4 - Added the achievement 「情報化産業」 for transmitting an active signal directly from one circuit board output to another circuit board input through wire or insulated wire. - Extended the 8-byte achievement mask to use its final bit, supporting all 64 current achievements. - Save snapshot/schema remains 40/49. + + +## v39.16.75 +- Fixed 「あぁ^~ たまらねぇぜ」: burst zunchi no longer treat their spawn overlap with the owner as a collision. +- A burst zunchi now becomes eligible only after it has cleared the owner once; a later fatal return collision unlocks the achievement, including routes that reflect and accelerate on a bounce fence. +- Preserved the existing nonfatal, stopped, and player-held disqualification behavior. +- Added route-level regression coverage for spawn overlap, owner clearance, accelerated return, fatal/nonfatal outcomes, and fatal live-ID release. diff --git a/FILES.json b/FILES.json index 897b5a4..db913a5 100644 --- a/FILES.json +++ b/FILES.json @@ -1,6 +1,6 @@ { "purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.", - "version": "39.16.74", + "version": "39.16.75", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/README.md b/README.md index b31a919..a73a824 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; - v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button. - v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field. - v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves. -- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.74`. +- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.75`. - v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons. - v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans. - v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed. @@ -72,7 +72,7 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re - v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons. -## Achievement system (v39.16.74) +## Achievement system (v39.16.75) - Sixty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, circuit-board signaling, and ant-related play styles. - Locked entries show the configured pre-unlock disclosure directly in the row; all other locked entries show `???`. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 show local progress percentages. Unlocked entries appear first within each category. - Each entry is a horizontal completion-rate bar whose background fill reflects the optional shared global unlock percentage; the shared achiever count appears beside the percentage. @@ -108,4 +108,5 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re - v39.16.72: Circuit boards now default to 10x10 with a 20x20 maximum, AND uses a two-cell height, compact gates can sit closer to top/bottom edges, placement previews follow occupancy footprints more directly, and unstable internal logic loops are explicitly forced OFF. - v39.16.73: Circuit-board editing now shows placement ghosts for internal parts and terminals, Q/E rotates the pending internal part by 90 degrees, ON constant sources are explained in the editor, and wire/insulated-wire palette silhouettes render correctly after the Electrical category move. +- v39.16.75: Fixed 「あぁ^~ たまらねぇぜ」 so burst zunchi ignore their spawn overlap with the owner, arm only after clearing the owner once, and unlock on a later fatal return collision including bounce-fence reflections. - v39.16.74: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire. diff --git a/app_manifest.json b/app_manifest.json index c4b33a6..2586049 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.16.74", + "version": "39.16.75", "css": [ "css/base.css", "css/layout.css", diff --git a/index.html b/index.html index 53b01dc..16b1bb4 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -292,173 +292,173 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js index 1639ed9..fef02e9 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -1567,6 +1567,8 @@ function resetWorldProgress(world = global.world) { if (!world) return false; + runtimeByWorld.delete(world); + dangerFightDeathsByWorld.delete(world); world.achievementDirectFeedCount = 0; world.achievementRobotCleanCount = 0; world.achievementEnemyAntKills = 0; @@ -1641,8 +1643,9 @@ const world = detail.world || detail.tarinai?.world || null; if (world) { evaluateMegalopolis(world, detail); + const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); + if (!state.unlocked.enemy_enemy_friend && aliveAfterDeath < 30) world.achievementEnemyAntKills = 0; if (!lifespanDeath) { - const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1; } } diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js index f369b3c..ce36c40 100644 --- a/js/item_dynamic_zunchi_system.js +++ b/js/item_dynamic_zunchi_system.js @@ -4,6 +4,22 @@ // Owns zunchi item motion, high-speed collision, and lifecycle stage ticking. // Prototype methods delegate here. (function (global) { + function burstOwnerHitRadius(item, tarinai) { + return (tarinai?.radius || 20) * 0.72 + (item?.r || 14); + } + + function updateBurstOwnerClearance(item, worldRef) { + if (!item?.achievementBurstOwnerId || item.achievementBurstClearedOwner === true || !worldRef) return false; + const owner = worldRef.liveTarinaiById?.(String(item.achievementBurstOwnerId)) + || (worldRef.tarinai || []).find(candidate => candidate && !candidate.dead && String(candidate.id || "") === String(item.achievementBurstOwnerId)) + || null; + if (!owner) return false; + const clearance = burstOwnerHitRadius(item, owner) + 3; + if (distXY(item.x, item.y, owner.x, owner.y) <= clearance) return false; + item.achievementBurstClearedOwner = true; + return true; + } + function updateMotion(item, dt, worldRef) { if (!item || item.dead || item.type !== "zunchi") return false; if (item.playerHeld || item._heldByPlayer) { @@ -37,6 +53,7 @@ worldRef.drawListDirty = true; } global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef); + updateBurstOwnerClearance(item, worldRef); const collisionSpeed = Math.hypot(item.vx || 0, item.vy || 0); if (collisionSpeed < 0.08 && item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false; resolveHighSpeedTarinaiCollision(item, dt, worldRef, collisionSpeed); @@ -50,7 +67,13 @@ for (const t of worldRef.nearbyTarinai?.(item.x, item.y, search, true) || []) { if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; const d = distXY(item.x, item.y, t.x, t.y); - if (d > (t.radius || 20) * 0.72 + (item.r || 14)) continue; + if (d > burstOwnerHitRadius(item, t)) continue; + const hitTarinaiIdBeforeDamage = String(t.id || t.releasedLiveId || ""); + const isBurstOwner = Boolean(item.achievementBurstOwnerId && String(item.achievementBurstOwnerId) === hitTarinaiIdBeforeDamage); + // Burst zunchi spawn inside their owner. Ignore that initial overlap + // completely; only a projectile that has first cleared the owner can + // return and become a valid self-collision. + if (isBurstOwner && item.achievementBurstClearedOwner !== true) continue; const now = worldRef.time || 0; if ((item.lastHitTarinaiAt || {})[t.id] && item.lastHitTarinaiAt[t.id] + 0.55 > now) continue; item.lastHitTarinaiAt = item.lastHitTarinaiAt || {}; @@ -62,8 +85,15 @@ t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); if (speed >= 185) { const damage = Math.min(18, Math.max(1, (speed - 185) / 22)); + // A fatal damage call releases the tarinai live ID before control returns here. + // Capture ownership eligibility first so a valid self-zunchi death is not lost. + const eligibleSelfZunchiDeath = Boolean( + isBurstOwner + && item.achievementBurstClearedOwner === true + && item.achievementBurstNeverStopped !== false + ); worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81"); - if (t.dead && item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false) { + if (t.dead && eligibleSelfZunchiDeath) { globalThis.TarinaiAchievements?.recordSelfZunchiDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed }); } } diff --git a/js/version.js b/js/version.js index 7c9c287..47ec12e 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.16.74"; - const APP_BUILD = "tarinai-ui-v39-16-74"; + const APP_VERSION = "39.16.75"; + const APP_BUILD = "tarinai-ui-v39-16-75"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 8c588a5..b093a03 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -392,6 +392,10 @@ it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i); it.achievementBurstOwnerId = source?.id || ""; it.achievementBurstNeverStopped = true; + // A burst starts inside the owner's body. It must clear the owner once + // before a later return can count as a self-hit; otherwise the spawn + // overlap itself consumes the hit and slows the projectile. + it.achievementBurstClearedOwner = false; it.achievementBurstLaunchedAt = this.time || 0; if (pin?.type === "oshibyo") it._achievementDangerSource = pin; it.stage = "fresh"; diff --git a/scripts/achievement_integration_audit.py b/scripts/achievement_integration_audit.py index 4c01b0c..4221adb 100644 --- a/scripts/achievement_integration_audit.py +++ b/scripts/achievement_integration_audit.py @@ -35,7 +35,8 @@ checks = { '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 unlock checks owner and never-stopped': 'item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false' in read('js/item_dynamic_zunchi_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 field progress persists in saves': 'achievementDirectFeedCount' in read('js/snapshot_system.js'), diff --git a/scripts/playstyle_achievement_audit.js b/scripts/playstyle_achievement_audit.js index 27fb1dd..bdec505 100644 --- a/scripts/playstyle_achievement_audit.js +++ b/scripts/playstyle_achievement_audit.js @@ -161,6 +161,12 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st 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"); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index f452fc6..56d12e1 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -2016,6 +2016,15 @@ const vm = require('vm'); const context = {{ console, globalThis: null, window: null, clamp: (v, lo, hi) => Math.max(lo, Math.min(hi, Number(v) || 0)), + TarinaiGeometry: {{ + pointSegmentDistance(px, py, ax, ay, bx, by) {{ + const sx = bx - ax, sy = by - ay; + const len2 = sx * sx + sy * sy; + if (len2 <= 1e-12) return Math.hypot(px - ax, py - ay); + const t = Math.max(0, Math.min(1, ((px - ax) * sx + (py - ay) * sy) / len2)); + return Math.hypot(px - (ax + sx * t), py - (ay + sy * t)); + }}, + }}, audio: {{ damage() {{}}, play() {{}}, death() {{}} }}, }}; context.globalThis = context; diff --git a/scripts/self_zunchi_regression_audit.js b/scripts/self_zunchi_regression_audit.js new file mode 100644 index 0000000..1bbb14c --- /dev/null +++ b/scripts/self_zunchi_regression_audit.js @@ -0,0 +1,129 @@ +#!/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"); diff --git a/service-worker.js b/service-worker.js index 2a16e3d..52b8383 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.16.74"; +const APP_VERSION = "39.16.75"; 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.