From d163ceb291d260154f4de80b5ba831cf227f4c78 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 18 Jul 2026 22:15:26 +0900 Subject: [PATCH 1/4] e --- BUILD_NOTE.txt | 152 +++---- FILES.json | 2 +- achievement_api.php | 1 + app_manifest.json | 2 +- assets/sounds/achievement_unlock.mp3 | Bin 0 -> 54144 bytes css/components.css | 16 +- css/layout.css | 8 +- index.html | 372 +++++++++--------- js/achievements.js | 41 +- js/ants.js | 14 +- js/audio.js | 1 + js/circuit_board_system.js | 2 +- js/colony_situation_system.js | 11 +- js/data.js | 2 +- js/fire_runtime_system.js | 14 +- js/health.js | 4 +- js/history_system.js | 4 +- js/impact_response_system.js | 2 +- js/item_dynamic_ball_system.js | 22 +- js/item_dynamic_pin_system.js | 8 +- js/item_dynamic_zunchi_system.js | 6 +- js/item_lifecycle_support.js | 4 +- js/physics_helpers.js | 2 +- js/render.js | 150 ++++++- js/robot_cleaner_system.js | 36 +- js/signal_system.js | 4 +- js/simulation_creature_system.js | 55 ++- js/simulation_effects_system.js | 40 +- js/simulation_maintenance_system.js | 40 +- js/sound_pack.js | 7 +- js/structures.js | 2 +- js/tarinai_action_definitions.js | 2 +- js/tarinai_building_behavior.js | 1 + js/tarinai_cursor_care_system.js | 10 +- js/tarinai_direct_feeding_system.js | 13 +- js/tarinai_disease_nest.js | 15 +- js/tarinai_identity_social.js | 21 +- js/tarinai_needs_items.js | 22 +- js/tarinai_nest_sleep_system.js | 20 +- js/tarinai_render.js | 8 +- js/tarinai_social_action_runtime.js | 3 +- js/tarinai_social_move_life.js | 34 +- js/tarinai_update_step_frame.js | 4 +- js/tarinai_update_step_movement.js | 8 +- js/ui.js | 5 +- js/ui_bind.js | 65 ++- js/ui_family_render.js | 47 ++- js/ui_input_shared.js | 1 - js/ui_log.js | 92 ++++- js/version.js | 4 +- js/weather_system.js | 4 +- js/world.js | 4 + js/world_combat_effects.js | 47 ++- js/world_environment.js | 35 +- js/world_family_social.js | 56 ++- js/world_pathfinding_system.js | 13 +- js/world_placement_log.js | 28 +- js/world_spatial_budget.js | 8 + js/world_tool_actions.js | 13 +- js/world_view.js | 3 +- ...ievement_audio_tooltip_regression_audit.js | 22 ++ scripts/achievement_audit.js | 22 +- scripts/achievement_integration_audit.py | 4 +- scripts/effect_runtime_regression_audit.js | 9 +- .../long_session_runtime_regression_audit.js | 84 ++++ .../object_render_cursor_regression_audit.js | 25 ++ ...een_motion_performance_regression_audit.js | 16 + scripts/playstyle_achievement_audit.js | 2 +- scripts/regression_check.py | 15 +- ...oduction_memory_sprite_regression_audit.js | 26 ++ scripts/reproduction_regression_audit.js | 27 ++ .../secondary_achievement_regression_audit.js | 15 +- .../social_interaction_regression_audit.js | 5 +- ...ltip_nail_colony_focus_regression_audit.js | 23 ++ scripts/ui_grass_limits_regression_audit.py | 2 +- service-worker.js | 2 +- 76 files changed, 1385 insertions(+), 524 deletions(-) create mode 100644 assets/sounds/achievement_unlock.mp3 create mode 100644 scripts/achievement_audio_tooltip_regression_audit.js create mode 100644 scripts/long_session_runtime_regression_audit.js create mode 100644 scripts/object_render_cursor_regression_audit.js create mode 100644 scripts/offscreen_motion_performance_regression_audit.js create mode 100644 scripts/reproduction_memory_sprite_regression_audit.js create mode 100644 scripts/reproduction_regression_audit.js create mode 100644 scripts/tooltip_nail_colony_focus_regression_audit.js diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 55074fa..a5d7199 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 51 is the current save format; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and persistent statistician progress while v5 remains importable. -- Version: 39.16.94 +- Version: 39.16.109 - 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. @@ -15,92 +15,92 @@ tarinai_compact_save_stage4 - Signal effects use the exported TarinaiEffect constructor. - Pressure-switch placement/editor overlays display the configured detection radius. -- v39.16.18: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths. +- v39.16.105: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths. - Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect. -- v39.16.17: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper. +- v39.16.105: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper. -- v39.16.16: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic. +- v39.16.105: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic. -- v39.16.16: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced. -- v39.16.16: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction. +- v39.16.105: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced. +- v39.16.105: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction. -- v39.16.16: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution. +- v39.16.105: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution. -- v39.16.19: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks. +- v39.16.105: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks. -- v39.16.20: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source. +- v39.16.105: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source. -- v39.16.21: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state. +- v39.16.105: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state. -- v39.16.22: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner. +- v39.16.105: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner. -- v39.16.23: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label. -- v39.16.25: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included. +- v39.16.105: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label. +- v39.16.105: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included. -- v39.16.29: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C. +- v39.16.105: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C. -- v39.16.29: aligned parasol placement overlay with its shade, made toilet sand absorb rain water, expanded Happy/Athletic/War presets, simplified Athletic rotators, and reserved their full rotation envelope from fences. +- v39.16.105: aligned parasol placement overlay with its shade, made toilet sand absorb rain water, expanded Happy/Athletic/War presets, simplified Athletic rotators, and reserved their full rotation envelope from fences. -- v39.16.30: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup. +- v39.16.105: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup. -- v39.16.31: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes. +- v39.16.105: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes. -- v39.16.35: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops. +- v39.16.105: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops. -- v39.16.37: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips. +- v39.16.105: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips. -- v39.16.38: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised. +- v39.16.105: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised. -- v39.16.39: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons. -- v39.16.40: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background. +- v39.16.105: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons. +- v39.16.105: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background. -39.16.41: Added local achievements and optional self-hosted shared unlock percentage API. +39.16.105: Added local achievements and optional self-hosted shared unlock percentage API. -39.16.42: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking. +39.16.105: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking. -39.16.44: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements. +39.16.105: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements. -39.16.44: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing. +39.16.105: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing. -39.16.45: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress. +39.16.105: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress. -39.16.46: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field. +39.16.105: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field. -39.16.47: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button. +39.16.105: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button. -39.16.48: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics. +39.16.105: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics. -39.16.49: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss. +39.16.105: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss. -39.16.51: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution. +39.16.105: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution. -39.16.52: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog. +39.16.105: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog. -39.16.53: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left. +39.16.105: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left. -39.16.54: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync. +39.16.105: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync. -39.16.55: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration. -39.16.56: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards. +39.16.105: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration. +39.16.105: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards. -39.16.57: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server. +39.16.105: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server. -39.16.59: Replaced JSON-string achievement metadata in spell/save binaries with a sparse binary schema; achievement unlock masks are raw bits, progress is bit-packed, timestamps use minute deltas, and old v39.16.57 spells remain readable. -39.16.60: Added persistent sniper, megalopolis, and love-mochi fertility achievements; changed chaos progress to fight-mochi-influenced fights only; extended compact spell progress without migrating obsolete generic fight counts; omitted completed achievement counters from spell metadata; and tightened population-limit labels/layout. +39.16.105: Replaced JSON-string achievement metadata in spell/save binaries with a sparse binary schema; achievement unlock masks are raw bits, progress is bit-packed, timestamps use minute deltas, and old v39.16.105 spells remain readable. +39.16.105: Added persistent sniper, megalopolis, and love-mochi fertility achievements; changed chaos progress to fight-mochi-influenced fights only; extended compact spell progress without migrating obsolete generic fight counts; omitted completed achievement counters from spell metadata; and tightened population-limit labels/layout. -39.16.62: Expanded achievements from 54 to 59 with single-digit FPS, 24-hour continuous play, five toilet-sand placements, park-ground editing, and four ground changes per second; revised safety, observation, and empty-click conditions; moved the two observation achievements to the bottom of その他; and expanded binary achievement masks to eight bytes while retaining schemas 41–43. +39.16.105: Expanded achievements from 54 to 59 with single-digit FPS, 24-hour continuous play, five toilet-sand placements, park-ground editing, and four ground changes per second; revised safety, observation, and empty-click conditions; moved the two observation achievements to the bottom of その他; and expanded binary achievement masks to eight bytes while retaining schemas 41–43. -39.16.63: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4. +39.16.105: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4. -## v39.16.64 +## v39.16.105 - Added 毎日たりない観察 and 抵抗器じゃない. - Daily streak state is carried by achievement payload v5 and binary schema 46. - Achievement aggregate JSON migrates compact v2/verbose input to compact v3 automatically under the API file lock. -## v39.16.65 +## v39.16.105 - Re-audited the not-yet-deployed shared achievement server with generated randomized unlock data instead of a fixed production sample. - Shared summaries are read-only; only POST session/unlock/sync/reset calls register or update players, and GET mutations are rejected. - State writes now use a separate lock file plus atomic same-directory replacement, preserving the previous state across interrupted writes. @@ -108,26 +108,26 @@ tarinai_compact_save_stage4 - `completionistEligible: false` now prevents `true_tarinai_observer` from being re-added by the same sync payload. - The randomized server audit covers 32 v2/verbose migrations and 72 concurrent player syncs with exact semantic comparison. -## v39.16.66 +## v39.16.105 - Added seesaw cooperative play and friendship growth. - Added circuit-board editor with positioned input/output terminals and AND/OR/NOT gates. - Added electrical control for fan, stove, dry ice, and duplicator. - Added connectable-item and circuit-terminal highlights while wiring. - Save snapshot/schema: 38/47. -## v39.16.67 +## v39.16.105 - Circuit board editor now uses a grid canvas with direct gate placement and cursor-drawn wiring. - Legacy circuit layouts are intentionally not converted. - Colony state evaluates every two in-game hours; growing populations cannot be classified as Weakened. - Seesaw action selection, fixed orientation, and placement preview were corrected. -## v39.16.68 +## v39.16.105 - Seesaw leisure can start at a lower boredom threshold, and a waiting rider raises the second rider's action priority. - Active paired play is shown as 「シーソーをしている。」 in selected Tarinai data. - Seesaws render in the low/back item layer instead of covering Tarinai sprites. - AND/OR gate input pins and gate outputs are snapped to the same 40px circuit-editor grid used by cursor wiring. -## v39.16.69 +## v39.16.105 - Circuit-board internal wiring now uses straight grid segments with explicit branch junctions and unlimited fan-out per terminal. - Removed OR and added a one-input/one-output rectifier; internal logic parts rotate in 90-degree steps. - Circuit-board external terminal counts and positions are editable; the board footprint and default terminal spacing were expanded. @@ -136,7 +136,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema: 39/48. Older saves are intentionally unsupported. -## v39.16.70 +## v39.16.105 - The default circuit-board grid is now 10x6, one quarter of the previous editor area, with a correspondingly smaller world footprint. - Circuit-board width and height are editable; the board world size follows the grid dimensions. - Input/output terminal count is limited by board height, and every external terminal can be dragged independently onto the left, right, top, or bottom edge. @@ -147,7 +147,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema: 40/49. Older saves are intentionally unsupported. -## v39.16.71 +## v39.16.105 - Circuit-board external terminals are now placed directly with IN/OUT terminal tools; the +/- terminal-count controls were removed. - Disconnected external terminals are dimmed in the circuit-board editor; connected terminals retain their signal colors and cannot be deleted until their world wire is removed. - Placement previews no longer use the generic ellipse fallback: circle items use circles, obstacle/mechanical items use their collision footprints, and special items use dedicated shapes. @@ -156,7 +156,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. -## v39.16.72 +## v39.16.105 - Circuit-board default grid is 10x10 and both dimensions can grow to 20x20; the world footprint scales proportionally in both axes. - AND gate body height is two grid cells. Compact one-input parts can be placed one grid row from the top/bottom edges when their rotated pins remain in bounds. - Placement previews prefer shared occupancy/collision footprints; the dedicated seesaw preview was removed and circuit boards use their rectangular board footprint. @@ -164,7 +164,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. -## v39.16.73 +## v39.16.105 - Circuit-board editing shows placement ghosts for pending internal parts and terminals. - Q/E rotates pending internal parts by 90 degrees before placement. - The ON constant source is documented in the editor. @@ -172,14 +172,14 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49. -## v39.16.76 +## v39.16.105 - Removed the circuit-board OFF constant source. - 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.78 +## v39.16.105 - 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. @@ -190,15 +190,15 @@ tarinai_compact_save_stage4 - Undo/Redo and valid ground changes now reset 監視カメラ observation progress as world interventions. - Added route-level and secondary regression coverage for these achievement paths. Ambiguous achievement semantics identified by the renewed audit remain unchanged pending explicit specification. -39.16.78: Applied confirmed achievement semantics: birth-status slave/king appearances count; dynasty achievements require direct parent-child qualifying lineage; Overprotective treatment counts only actual beneficial health recovery; Unplanned City counts deletion operations rather than removed objects; Surveillance Camera requires 3 uninterrupted game days and resets on gameplay interventions; wire-shock partial progress remains nonpersistent; Pest Control counts only player-caused ant deaths. +39.16.105: Applied confirmed achievement semantics: birth-status slave/king appearances count; dynasty achievements require direct parent-child qualifying lineage; Overprotective treatment counts only actual beneficial health recovery; Unplanned City counts deletion operations rather than removed objects; Surveillance Camera requires 3 uninterrupted game days and resets on gameplay interventions; wire-shock partial progress remains nonpersistent; Pest Control counts only player-caused ant deaths. -## v39.16.80 +## v39.16.105 - Optimized Tarinai social-neighbor scans with squared-distance filtering, nearest-N partial selection, pair-deduplicated symmetric work, cached personality profiles, and shared disease spread logic. - Added personality-derived crowd comfort bounds. Overcrowded Tarinai flee toward less crowded space with the specified behavior text; undercrowded Tarinai become stressed and approach the nearest living Tarinai with the specified loneliness text. -## v39.16.85 +## v39.16.105 - Replaced Undo/Redo object snapshots and recursive patch history with the existing synchronous binary save codec payload. - Exposed binary snapshot encode/decode helpers from TarinaiSaveCodec for internal history reuse without Base32768 text conversion or asynchronous compression. - Replaced JSON.stringify-based duplicate detection with a compact dual 32-bit fingerprint over the encoded history bytes. @@ -206,7 +206,7 @@ tarinai_compact_save_stage4 - Added binary-history regression coverage for storage shape, duplicate suppression, Undo restoration, and Redo restoration. -## v39.16.85 +## v39.16.105 - Render stack optimization: static visible stacks are cached by static spatial generation and camera region, while dynamic stacks reuse previous-frame order before insertion sorting and are merged linearly. - Link rendering now uses dedicated AABB spatial cells instead of per-frame full type-bucket fallback scans. - Carried plushies render at owner-relative positions without mutating simulation coordinates during rendering. @@ -214,32 +214,32 @@ tarinai_compact_save_stage4 - Render-layer sort keys are computed once per stack insertion. -## v39.16.85 +## v39.16.105 - Reduced fixed render cost by skipping the fallback full-screen gradient whenever the transformed field fully covers the viewport. - Cached full-screen fallback gradients and rebuilt atmospheric full-screen lighting at 8 Hz instead of constructing multiple full-screen gradients every frame. - Reused dynamic render-layer entries, sort maps, and previous-order ID arrays to reduce per-frame garbage collection as population grows. -## v39.16.86 +## v39.16.105 - Removed social crowd-retreat selection and the overcrowded colony-state distance scan. - Removed the unconditional three-second full terrain-cache invalidation; cached terrain now refreshes only from real terrain changes, with splat fading invalidating local chunks. - Throttled structure dependency safety sweeps, staggered item/ant compaction, and stopped timer-only item bucket rebuilds. - Deferred periodic statistics UI refreshes and collapsed population statistics into a single pass. - Ordinary item additions/removals no longer invalidate the full terrain cache unless cached terrain items actually changed. -## v39.16.88 +## v39.16.105 - Large-save restore uses an adaptive non-damaging settling phase for dense loaded populations, preventing synthetic body-overlap and obstacle-correction impulses from cascading into impact deaths. - Duplicator loading takes priority over direct feeding when a Tarinai overlaps the machine. - Save schema 50 explicitly stores elapsed completed days; total simulation time is restored monotonically and seasons/climate derive from elapsed days. Schema 49 spells remain readable. -## v39.16.89 +## v39.16.105 - Moved the ずんちどれい and たりない王 population-chart toggles to immediately after 死亡数. - Removed the 12-second periodic Tarinai runtime-cache full sweep that caused large-population performance regressions. - Runtime relationship/cache cleanup is now scheduled only after accumulated deaths reach max(20, floor(living population * 15%)), then runs once during the next Tarinai compaction and resets its counter. -## v39.16.90 +## v39.16.105 - Added eight playstyle-survey achievements: 数こそ力, 少数精鋭, 踩屎感, 快適な寝床, 石に枕す, 季節を越えて, 推し, and 統計学者. - 数こそ力 unlocks at 100 living Tarinai. 少数精鋭 requires 1-25 living Tarinai continuously for five game days and discloses its condition/progress before unlock. - 統計学者 counts 30 statistics-tab or chart-series visibility button presses in persistent achievement state, so field resets do not clear its progress. @@ -247,7 +247,7 @@ tarinai_compact_save_stage4 - Updated the Item lifecycle pipeline regression to validate local bucket/spatial invalidation when passive serving-food decay actually changes its visual footprint. -## v39.16.94 +## v39.16.105 - Removed previous-order render Maps and custom insertion sorting; dynamic visible lists now use native Array.sort after culling. - Removed the shared route cache while retaining actor-local route throttling. - Disabled detailed performance counters in normal play; diagnostics remain available only in explicit debug/perf mode. @@ -256,7 +256,27 @@ tarinai_compact_save_stage4 - Added sustained hysteresis to automatic performance-profile transitions to prevent rapid quality oscillation. - Death-reference cleanup now executes in bounded per-frame chunks after the existing adaptive death threshold. -## v39.16.94 +## v39.16.105 - Fixed dense-population birth regression caused by incremental Tarinai spatial-grid bucket ordering interacting with an early 20-candidate contact cutoff. Contact interactions now consider the complete already-fetched local candidate set while retaining only the nearest bounded interaction list. - Moved the "季節を越えて" achievement from the ecology category to "その他". -39.16.96: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed. +39.16.105: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed. + +- v39.16.105: restricted in-world item tooltips to sleep furniture; delete tool now removes lodged pins after pinch-equivalent detach effects; raised confusion/breeding colony-state thresholds; clicking a Tarinai scrolls the right panel to Tarinai Data. + + +## v39.16.107 +- Long-session runtime caches now expire independently of deaths; recurring fight resolution no longer creates permanent time-bucket keys. +- Visual effects OFF now gates effect creation/update/drawing, including rain, while ON keeps the existing visual budgets unchanged and uses broader effect pooling plus allocation-free update traversal. +- Hidden family trees no longer run full graph normalization/pruning from simulation maintenance; pending cleanup is handled when the family view is opened. + +## v39.16.106 +- Renamed the achievement 「メメント・モリ」 to 「多数の死は統計上の数字でしかない」. +- Replaced push-only log kinds with six shared event categories: 生命 / 危険 / 交流 / 生活 / 環境 / 観察. Category toggles now filter both the event UI and push notifications. +- Prevented family-tree layout work before first use and while the family section is inactive; tab switches reuse family caches, cancel offscreen in-progress layout work, use one activation event, and throttle scroll-driven tab detection to animation frames. +- Manual Tarinai addition now resets 監視カメラ observation progress as a player intervention. + + +## v39.16.109 +- Repaired family-tree activation after lazy-render cancellation and added blank-view recovery rendering. +- Robot cleaners keep obstructed cleanup targets and route around nest boxes/fences instead of permanently excluding nearby zunchi. +- Added the achievement 「マッドサイエンティスト」 for administering mystery drug to 10 distinct Tarinai. diff --git a/FILES.json b/FILES.json index 22c5608..80f5ee5 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.98", + "version": "39.16.109", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/achievement_api.php b/achievement_api.php index d439797..3b7a2c8 100644 --- a/achievement_api.php +++ b/achievement_api.php @@ -81,6 +81,7 @@ const ACHIEVEMENT_IDS = [ 'well_informed', 'lively_making', 'memento_mori', + 'mad_scientist', ]; const DATA_DIR_NAME = '.achievement_data'; const DATA_FILE_NAME = 'state.json'; diff --git a/app_manifest.json b/app_manifest.json index 48459a8..3dd37b6 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.16.98", + "version": "39.16.109", "css": [ "css/base.css", "css/layout.css", diff --git a/assets/sounds/achievement_unlock.mp3 b/assets/sounds/achievement_unlock.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9d814a7261bc377497bf2fe108402e6af9309c57 GIT binary patch literal 54144 zcmeF&)l(eL7bx&W7FdET?(XgmVR3i2;O-tIxLc6LA-KC+aCdhIE>;M43xADn@TLWNaD19)?z60>aHDuSj z0WeY36$+$tFwJ8h{sbXR#UG`pG&^uD^X~B<@U0>ls#1xWs}7;CQA9;p5Q42>k;Ue= zLo;O(yLPtkh(fe8fBBRCSVkc7Rj@9&|J{O0KZ7=T#X&TB{k|4@ibO56_la34yB>T*&)ZWo-Nwsc=FTkhQySsU+xz=Fvw(LZ z6PWwcGX#MA@Xj$PtPsSHLg|uB*Y`zdR2%O>Ysqa(l@#EI4Jph~9D15()3>N}C8p{x3%&pX|hcNQwH2lvO3*s-KbQ zN?WOrSWVOEtGmAFv+POt+h$`cE<~3;zX6g$QOL0)0pU2i^#ID`%wlwL>PFj_u!}x; zY=r;jq6rtGhHr=kTSFBjlk=AaQ*U8Ihu*>eMV!jExL{wj#e@Rs+XTmZ;->k>i&WC7K`HLB-r{pZ0%!bW!dXkfBvR10<0nfdMq%gW$+g ze3BO_P7_P!RmN@Vhw>$qqsVDZB&o%|qJ%a5=Laha=wPg6&!&v2$7J`6Pur=j=ct4# zA{^{p!*rL1+s85{r5MN~B7Q{8v|q)*-VjgRUP;te`G(?)3A6d=1LJt3nBb z(yp#Yt8>+95PhBXQ8GE8JIY@`p8_r%S;MoD0>|UT^M+!t(ZkVrM0|Klb~Loc447|U|MAg7 z4wk~tz@Zs?d1uu6>*LMdRMZ!1=LPA_i!LNUoIJN-up$b5A7lZ5V$uC5lT0~2x|8^Tb+}a>JO!eDzm=_+^({Az*^Qi&G)y%b;Nn>%zkdvK#byfI?`6eq( zb|xx9)>+isPCp_abJTlk{ZD{X@|V7D9{G|HCe$})+y+$2ek}I7Y6(qTTKXS5>}A^J zoA6dZxE5NUUiS?>l+F1TJk~Q6-kbCqeV(k8c=63YU~#mF$(Qy;oCWmi^}kCm85$qM@K-Om1Xv&t@{Ljf)Yi z;3(oB>{ex0e@9Z!+4+(d3>rM_JM>teF-k?lU_!_3o`ia3F7cz{;J2yda^ZECVoTCx zeCIjaYTg&DlaY>%KNb&{l2?e=iy@Dtl^VITop*lFi_ht1%D#dzvolOVZ zvm<5{*R3mw7_8^KpI4!ptC4>ylY!Mnj3g$iUKiI;G0RL==|CKd5}?fgsRD{e?zxMd09Xq0#S*J_bYmhv-bP@_T@H; z$}j#-Jv2r%gIaZ%bw>;K`4`RdGC`WDRAOo)3jjSMGA8`2h)QG>zA1enKZ7<-q?0&Y zNssyi8X9-lO@y-$D+N6=9Hp7fA7Oz%HKd_Pr~IHECK3|!_+ z54~sQdNA3n*!F=}Nq^^o9rFkTSAaa(-pN%YD37u~FjAsydzk6*waJ@wZ%e88zImXE zLD-RkUg+q~5__W?wPGqKv@gyFZ%)Zaf~6Dei4K%MGNZHv;;l`N|M8Faf1-pG15@L}Lc?E1RSpYFr+G0g*K z$NLS^c;cB|dEeK~9mjI;8`6u6m**m8+B=uX@a>6#4 zD#ApU(kk|yEiL&2FSYE{FZ!2x2pdDu?wzD)e$ib#hRs1sQRMP-5rn08so`^F3RH31 z_cV^W6iaBK%!z$q-{`2UDx6Wa5Cm*-&|imzI4toHMQ2lEdKUDM$cZu=TgRFDv-1Yr z3ZJ#ZfaGs|0@=9VqJJ%LaHGQfko27AW&r}vt?1bkKiGLJh*@h2l3p$aBjugZFdekC z0Ag1vhLEChmobBR{6uarqo%=mpJT?6R^`c&3gw^wfLM1j&j>6_4Ll5pi(g(95>~%Wy zz$(5+s>N|Xx9aUS)KX981MGn7Ur`E~IejtKU!3BA|Ds@XE7DA9%isJ+NPimcs9>=4 zA_Ri{2!vhNnQ`F>Y-8(L4efEbGj+!j)Vc*~&4u^;FjN2YLlgx3W<1}dVw`9(an>sW zjKir3mQxb`8*IT+$$e89m88=k7YB&4(8%{qzjQK1a{>?2BO6DW-ka614~5lyUy7S# zInsJyMhGCQ7MM0{z``V}F~@J>1_%-9&FrcmbLom5-<vYl%{6_<$-^RL~ymzYD{ojP*I5PAlCIQyf|KI;NGYSOs5 zXmT$2US$H~-|4Te$yT^QOPzp_-X*P?3_nl;cPxZffLt8{00g11v(Jt+nRLt1wbzRW znMvwq2Fv3srEEHmh+%tKJ2Zz!nUqC11nEJLLN7@z22?j10A*(1FVz3GTiCJcE*thF zwFHNUmRiLTF5_}*dUiu@gd&B&MgvC?xxzCVt2e$wyCGWNmTN}eD5$5C!K%`$3ZbINa8_*_NI*G17s5;AQ&+0&c|o` zI!6mnZ4ZO3$QE_N=D({s-nYK{*EfaG(w0gfHMM-|Fn%BMo_*uhTkRpR*4L|sx3pSb zk;Htj3RG`9!&Z4R_@n-=iq|{gXj(@&W5=%zYN^iz_s4l;GN~b{MI!_u0E0m=_~5WO zezi6?v+4QjYJPrrNk(SYcCzBd7!AqvwLXR@1U~Ala2ayl!vh&picn`QR5mh=wQrf` z)zy)prSA)ZqX68+6-cEkES_lc*A~OiG2ExE;+88{pugrvOhl7M122qtLcs2u>wa(2 zcQU=omn76yw`2@&dm)qNHT6(X=w=W+DT7xu(_i_e3fXk2!M+nbQ5ySBfQo zvK%}@OMVh#U30oDIux)P3>_%Z^}?mF6m#Tea#JEbZ|~#^(mwT2-`J+@HvG1NhvOUA z@H^CYUaa?rb)BJin~#iHj_!SY)~UC4IivOyTFN=Ds^V!ldP|)?&cBcxjmnVGlF)2C z6d@BJ795O#Ngovlk1S|-Z?4OupFX${tN{WG3$5sxK3owUW$qFViot&1|g zkZ*$9gq4iqmT?J}@^%=5a9N%KrZz#q=gv|89mMGk%T2~pq{ibHTEXvvN)_nBhmmPLiu&)-!CQ0xLQ zkr`7l9?p(GKZfRTh?hbguTJNp>tgd3%vnGktwb>ho+Vs+2BT_Tl54Yh-^5k+F zyj-;cK%4_m!zOBW0)J!2z9k?6rKS%1N5Owd4j!rXV!5sUK+<*(tUGuaDMWrjHHJ`D z#h0<;TP{qVydGpeHfx&hhV`uIqoQ9#`3$!_lH4u7ZY=%E04Iw#t@6N1!KwlPIFraT z;YE}tuwnBFguFK}k~LJAV0cJ@hMRh`4@U@iSR@)qj`K;33FALMc(A}YZRJT78A=nG zi2zgJFb+eo@FfRvuuGoxUIZz9?CkK7{jM}fOyLPZZQk`da?V15I572?*6agXo6aK5 z#R#U(a}jiLBwRy-b4x(~uYo{n}#BaaFClSvGRR#=wg=bp5fCZ83!VK5&Bc>`S*-Ibz2%v zlfYd1TUTxvT_>T*d+1z$ZHa=?^JM;{bG%*M00oBvS&#mJ%J$V$sIW$C+k2;&MBlBm z`lMP25{1#Mb|3rETj8ho3B2byB?C{>eettfL0p`d`o)}oyG?<`^st5>i1?ZMZQ|$Z z#~@H@$U@F{S$TRf)WJ&C%6=4`Av;zuO@9+)Vxq|G#A%Q7^8-GjRvbXk{QDPuu_ak)A1pRHv> zFXA^l+pJdSa@YGlHGg2dP;sR$j%(d&PvYFv`;$Yo!71n=g}DdbtA!EJL}>%BA@yTY zOb(U1AyHy@4aPo9Ed5X4ZwBlDW0;_Daoq0k4s&u8Bmp6$5hkaJy8rwT0DyWXYW6CI zCaOaljw0x3_&*&vS&rP-EmCam9zH-sW?)frJUbWMf}uvq^)0N%dEr~cXeTWg2wjU8 zdN90)ucf#qd-8^f>23yoLr2@n!>;UOkfD~kA3hbSaOr~HEs=>1p4*0GpL{AK^GK3V zGPe^_+Z_F-kZ)6Uuw9-XNl%6<$=4CnwB?4W_pXzc=qshQ|Gccuf|0e`Q3=Vl83&}3 zt7%Wc6vC>aRylf7$eyev97Tg9RTxh$O_4jl3FvJ{T6I+M-*Y9Ect2>}bVQOSQfRZh zzY{F+-54J9u3o%9_s)55F99+d_Bx_To+% z3B@Cup*o;xvZ?*#b^9|&Ma*J0#%M5Nr325cs+y@WZ<%WIUEl7~en-G=OCCnah6r@w zs9Tm{@*zN{N3+UF14SIk??AN#M{LsEvj;Trp}aF*ouaOX=nJ~1vVSKNw}S@7h%y~2 zs)ezmmS#_>CN``kez+r8ghYv{MV{PsoZGu?0j;IfOG_c+>M-_d(osvzuw!g5t=})^I;Yz!U~MlTkb%O1*}WC}kJTr4yYkfSQLPZUnZwv$-($3}dT zxL3hw&s|S^s-UHLQ#JoZEP^oG!~JW1y~=IXb~Iio;XSX7vi&1{GCkR<@M0WDVzV?-SB#h= zlCUAPkS3%#+63-5PM<0MiW5wQ%iWOA*Jx!+VO2vCrAJQfmg~~*(M`-vqT1L*NVwZ? znhH46Z?bdPSTd(@h0mrtg(GB-kTOP27I~(uLuQEz>)yq~{{lvMD#eV$m07?Tjp@EL z0x~i&0t_O@EtXxH!03T4KS!ZCB?=)B5FH${1|nxDNX(3SdMJC%8A!bRMqXmJDjVvO zWx>G#kfNjtqD5X*b%OV2#3l+#$z?mHPa+ zr3+c;vy^o@ND?z$A7z4zQ^twmdJ+ZG+o%kQeQkMkmQV11J3R4vX!Dz1U46F7 z6_D5Hm`RWcN74FoJsTeXsdrHX`WF$qcLusxhpK&0tptuv#lopuhc1$RML7{VX{)Ss zS%AL6!jS39pyQ%c{BmtppZb|6hMj%4!g1@=nB-n(ypB*rN7D@m{a_6-B&ks?M6x48D z<1@FqV)2Hy$v_nDPg9i|cK>l+l!~QW8t_H@GAlk&&~NmosHwg=te)}tdU^|INXoS5 z-x>})4St6nH2(-O<`~Y0ZwKYge28`>@}7IO`45cffI=i9- zr#Y1ie%DRirM|u~)`EWcx|d%jA*RND>#&$YqOPAQ>>{kZ!*rYz zRpdx^o(abMiXBlB;Lu1??c@MR(0_B(VHFfty7+Nxwv2tZk3vybwVk{ijvLVSiGs#e9>xK=xq&SM;+w9y@@t)EM<1rI663I7JXH`29yoL+ z?TF>h>fj|w89J=d5Ha1SSCCCD*A1xY|IL7xMzN($6Y4!6uBRqx4MR@uM}Tx>WI&U$ zN@|)M$4^Tt`Fx_X7yha{lvf>EGMLwQAsg#Y$?rYa_6301WjlA!_fe5#?*?Yt6pqb9 zx^nSo`lC_A;I|wjxc$#ln>gw&Cf}N#WZOT&eR@BUC9tQCAN^V^9H2^$E8liq+ExIb`Q16ZMAxu<+skEt+;bUei1{VRTV8RrbmZaIh*y{!i?J_&H*l zEm{I}`ndwAT~1UoYTWWxi7`A@ghr@QU8)r(C1z8XwZRAltHt@p5rP{wf%gCW(1lQ+ zYc1K!;gPE|=y!|2+G!I6YE^e9_tVqb-=#wvihMuB+8M3jBJuF_aEv6PA{i zG@qOJoiaY`vwIPrPGu+b-eM5olcP(L&LEcK)p`(|L!PxR5u6~sJ;O53qY;UACEx$M zr~V5$I3EHJfoJI8&**Oa?zI+0-6ZgI#BMvbovr0hQt@r7@ z^Q|iIM07cI=TYDlg&759NFK`UHd_$*JWL;k!Wfe52|;EhA8^_O96J@qmagTCV@N~g zj$I5wez#d_D`BW1C4JG~_e#NYyh0$sUqr)#;7U>z?zL_hwZQ*XI?+!Y@?eeYc@i;R z(?#A2%9Fo8{UnfJ?X4$7x8Rm0@8wI!(Bg^usQx>^WnQd(UqIEv_NiWce7qn_`CI&O zr3OPlV26Db+GUErzVI?vBnISJTTuqKBBtYysk~KlWu`q^8c42`MBfy)eU+lAUdr6vtqr(F&kM!w1%RvFjX;U$uEqn~|B{9eMgX=z`vP ze=Fq9a~`)n9XLR~hR&LMut2#*Kb_`isf(SRLOBuR9jl=~Z)R%Lb)QbRKK~Hto&V1d z1OUHkpl#3giBhGKiHze&6FW0l#bM96pRLN_`z>gV$2h@>*daUcsB%&C@6=tJ)X9)%)1Ber&Y7zmx|N_*{POc{ zAU#kl{$>2l`AKv>?_%gvCza6o0P5LUpQN{RCx~zy_AL<>0e}!9czwG*GRF;RkPRy> zsjfGdt&XuJB30504~?)(j*GedJ#NylsU}>T5p-FQvK`?5!4=Z3#xVTy*?e@rUU%uX zvs(gId4iauPI+EPP0{gTKVD9Bcqa}Vc z;7J`h@;PFX;TLJbt2a^P%R!zjw0B2yDEe!plwxuGve89EPF-)b^z$%89iSer!Mg$c zb=dj&n+5?w?|0+MHf)Cp)K3#kZ@Uc9H%P3=MT>HYZg2TtQ`;cZe+S=-0(+kWcV0!W z{`P0~Mmj%UI$)?a)4ShmA%9pFgNZ|OAFF4VZ$^S=7EEE$yiP)cvTq!H#>)`5f*v7v zeIS(3O@h(#KTL5=!OvAcU7Nsr^oJujFGyyEvm6huLdY{lQp+H~DPa#q#6ZOhi_ z@}D0Q01#rJF{x|lqQcPGA_5zO2FGtdFA)s=FiLQ<4<*Q}g|t{-XNQ^Ska9cz+Y$sQy>rggY!IDik1&P|!KezHEw# z45pDrf@^~#rjGicW?SJFYI5{-vRzOzsz@z~Hh=DO%b7sZU|qW_qtCOgh<-JI;=*i) z$x<(TQeoyBZ`&u;{tH(qF8q{E$gciNNqm)Cb=cx9nr=>X1afm0lRFiaDKSHLmA(VE z9P@axmAYww(z3p-(g)A{2rFU2n1!ydl}$z}00q$j@@Xk8S@jlpR^h^hnMR4wAtY7| zYh8>82q0MlW*SCrCWsMc?}30-hNHP0$~IjK&%VJaL<}81-4{)qz+bL1HBWji@NY8@ zLlk*XXuAu%&btlVnJx#l{7M`gB)yfG)N-c@@6-a)1Nwkds&I30E18(2)Jcp<_uR?V zn&e2B)ZF36`80d!e4OPT3<1ZQ0v}6{gaW?FDC(l(Ft_ed1grHOx&>^s;nlyX;k8^= z%uY{q3BHnNi%43#UefN{3VyD^qz5-Xxi6cNqZ+%Ncn}a*V9gh5F#c&Fg8YuZN&6Z_ zds+LNajtt%5ph#+S~AUBarNI&8}5{3nv2(U^e?>=V8Y3nZNR>NzNAzA6_n~}*acO@ zt{$4_v8kjv)nGARt1% z6q<`q{z(@d(Ghkp5swu%Q^2a%kYZ-3PWJDFYF6*wJ8-8T=204Am!NgfdHz^g`b3Qrr+wS95;}p zEwUrNMQ+~|rw!X?JlXF=HM2%T%oTW|Si*@+lC$rb8U_8-@CDN7{hH8w=&M(aPJHG) zSBq;#EEDhAG=Cjhc{Y8=5LTkBL8OJ2F~xU7CjX*h--YAb+{vk{JAQM_M(UZYyEVEI zAqZeol!P_6z|bZQwyyac&OL0Se)tRb;C}hw;AXy*wC^eD>zCL55`O5JXs@0>E46;J zFL|s_& zCT4+XtZed3IrC9Im8FZ2`FbFY@}=hPLjV5#Ee<{?2no7HLFfU8wu%A*x4>Z?ey`m? zo6K$b&rdo4cx0ljA#L5ReR$RS+wC4<;UMYhio`8R_kXeF`7p2%4xK0+up6G3K!OUHV6-r~6N+s^;8u>d~=->h{)@1`!6N^qgU#V$2%o(;R{i zAm_;tpBVSFS>1)sR{$C{6XvH^zwftbV?qTmidYjAd-9D`B2RmA0E}5O`0}TRkMm+ z+*+be;rXAR$`I-`;}r!4>12&E1r*_7L9CyFdWypH{U320EN(%cPYgcs8#`Rg{HD@* z@7Zn*bj~x-;8qc5en>*indjBc8~IT?OUnof-HoF)BxeC$$TmDWs38;kt?;A4iZY%; zhwx=ItZpmSY&p&1IkH8%t#|C*u6r-^cnY8K2ndhr#8V*ADl^k%`G*;eQpVUblZT}5 zXY4oAXu%3WQqiGa@$?NADOYGbI51Gtpnv}k{FKaRVPp~-u!U4&fWCZHWqd$TgdRFN zVEf=gNm~%r);oy}H|m6$&Y@2;Dr|}2K&V*WXM?S(47P^E z?ra1<^Lytay(o1-Xhrg~gsNIoU3Se0QZ-}dZEfgoVhYojvu}mZzXCTlyXv~3N9DVk z3URJdW?Y+}woqx^G#Bi4#K286Pef7kHWvFiAzY_$zTYXx0hV&Q(Q+^e5X5q_y38g{ z#V+ao4a)?BvoxrLpoou=fbp+Qn#H%z*o-q#kLdeDrbGpM^8p3 zFQW^hOG^M$L00@bxtEe)@9yXNmevTIQzCc<-y|UFuEZ zM11pBbBBF;-CkAjlM^Pqm*go;|hSsNI?e5k0tH8D;ck6|9 z!?O0tzE1vO5N{$Tn3#b|JQRe4f{2O3LbxH@&8iJJ<$)4Ar%iHO6^td7K+hZg0N-q% z4a8xQI;T@6i673MCp1#oCvTRr6Mtu8TP+k5DHz!JN`7qIw7FpKEP{H}&&|>2MYbNd z)!Ss*vm<7-eX`yA=4`k*cv&(+J$Ep*oK|3GsD#u>rt|&d$`^gSvLc`h`&*VJ$b8UC z4xL0EohP5pFIYXIC5^+3u3WPKUE!~XQ7rp7SJq7$E08joa(&k;2o5YdrQF-2Oxma4 zzb%{avl>A}?6_bZkhXtVfGj;8>{X z)y!A|Ic^DO_WFq-IB!myW~iG^cz33DpmJjvPCFip%S_0%8BvM>!}sD+jJF+~L`-bx zs^qKeYGk7gU$%D7^A>Vd&WHP46^!A2lwg@tO}4PuR5i*uAmr-tyTnjirH zsjP^Vu;4-=jG+mb|M`IhN*ilypi8ubtJ*eF9EP zh!TQz3BjI%@-e8$?t)OmCC^L${puUi((Gwwqir^=e`R_Kr%!=+jK&>Eud6c=EXv`g zSZC3;EaRHH`i3={=&Os*fO35De|XAsBO3Zg@Fnwg@wpocsTEV%Qyk%)_p=*3ZzGNt`^{OY3~-<0vOo8udk~mL*``b|_h89N{g+ za9>v(+CwJ%C3J7wnx4mjc0+mv;#zH&S^gnYiZdY8XJL5%Q5KADp-#o93C6}ZO~;DD z@?SQHuQceOl?`38*lhcbDNe)NT&Eg2IkuWUvQj2xMGZH-3kU+pZMV|fk3^TcG0Nfh zGNwu-TCLldPg2e77TiWSTUw@szaw#v0e4)2RvH$S>kF=)9Y=1z>Ep1}O zo2>hLPsr1XiD2;;N?}KC)@n%27us3p_k4^XnEV1$_Xwp9dT_+9*Z_CklZ;M- zPyUg`s}otNeG&l@jhwzPlEku^5#h`ZTV8JJzM$Cq^vh>bk7A%gmxxTS+P90rqA@rL z3G1K*V>nTevP=z7u2VI8tBq+Cm)WP7b^e=!y{kO6^RQ6z(8e?Q&Qsd{qk1_(lai?) zhBx(&&UnyCGjf|5Uu+nutLEO!!h_Lzesaf$=|? zVkpPt7hTpUvbg0Yn{Ws%pG`|}oW3{mIE5SSJ-!6KB441u&oWh6=-9Joarx3+&Mcg% zIpX(0Q;4T%M|W374pn7*K!2}s0GQ;|U!Tm3puFOj&(ZkgVjPve7@cV=gk%;|p=|swgD_z~|1b%(m9YQ%)BM zt1@an2t*YVaW1h(`L4)u6FvvpN2z6BAoGbUJTtw~A2k0tX zP!FmwD@Il;uE!yi+s*PKUYBD0S-0ti(~G^f`V0DS83}(>qOL%5QYUTw(5}7aJ2u@Q z*R=@+of+V#Iy_7Vu`KQn&hC##LV8n~2#zxB;7HiiNea}$1CUm`8f#!Y8q-CuyNlQ%5I8?02Oi7^t4z+2teM&HQar%7|RUT zo)ukeoA1Q;gBv(rw(Kigsz0X>Q(*3zlvSFu;D20kAB5#=a8JzU6%g-iqs*LK<)&SN z7QfNCiX~OT`{uAkZ)+=w+*iWF0|2nmMsTpiTzlbQT$8~vlRhAmLy<(vmpQX5gSeQB z{bIqi;8ACY%QbuCP02L>n0}1{t+5n+oI)7^lQ%A%6%o&c>?to-m*nQRK*+M`UT;@@Ur1*ry0o3>w6jVPA7V#A); zOD$H?4TWOy>}$H&Z;f1J5z|dj9Kik4GDHWEm%=qHq4?&1Pv5S+h+I=^%%3Jw>+d7gY47BY>l5%Lrr->aA6YQ z=`q)Sdo_dK@)@Ww__U1t(a1KILJ`|Td{_F)C-@LbZ970J)ngAUSpmswuwe^?=1eXceuD@`Nv5px2)grkh}oE&NDrdEZ6TrR66WjIx5hwNl9IOXic9m z)n5NsgnhgCH{!tDIuhE#z{c7LMtP3)Pd=733ckmE6!f`}L>TQQ3<{ih6v|cMw^+;A z>Nw>JRUwB|VLi2q1&MK_@^a#rTuG0fUBrU0P$V?U`d!_pXi)yaTK=6!e=x2IEYY%F1RJdh zr5x?~p*TN(nNLJ3uInd!w*wdb$QE1)stFdmz)FO@f)?a;-_;y-@>V>f!oC|MULz4y zninRY&2jNGP)uU1CY7Wa^gBt=%#v;NzcM0RjG`GPe6zVU~nO zVaD#bqm|)(wCIA3ss)RMWhKO=FrntondW)k136}H#-a+T+G5Y8q{B-j?#g`z8jgtL zKb9-AH$RXE&<6H@UMHx06%YEKA6THE!N2%Nao@PSiiCY2O&jE=!Q4O`?OMw6FMrYA zp|0werV`K{h9-FX{vJT#8;hU&@y9crU~NcSp!3$*htuwusPjYT{;H7kiS){VQYj_b zVN3Y2>f_dA!t2tj#w;W-IRF6AEe=wfA1D_D@FQbJMBvMF7a~)SXouu}zgA0}qguqn zMk8*7jI%MWG3t=c5R>3JO4b-ht1m_K);C6A8fJz@jCtl@|N0h8326fgY@?Mm;@JQtzo{@pbfrG_!j+ zba-Pgyfpmb%oTZvJ#-|gOXO3`y|Y&djXT_`L$3nA`aij|$=f-$8cl<>DR0#56hv(3UF}~nl zQqScKBsauz;#5~l!4e+mXu#0dA5ev4{lfH9|7if!ZZ&?cQ~2chq)i>^wTlpoZ^wPc zy0=b^2rJ?GHOEHr2CXIq!-EEMO(-djvaxJ9Mf`G?x|F!3u)N$68c}jzt4OiOf)SPU zci}X4Y}MWvrQ+yl!%3rY-B5#jHpE$UU_^&3*2GWBKuRdmQ~n3hRzby2^0Y{I)d^S& zOSJN^AaM=bxN6Omlk1{s`_p0ty8rxK!U6LPmJU>S8_jGDM1%*LaZ>FyP}u{b-AgXM zKY->^#j^}G(}L%2v-g>+W>w8Ws}x1VF*Rx*JQcB;Mi850vftK!o@LMxju(Tzy=oD? zOY43a zQm}S62jz!M6xRB^zz%K92UU=xIsePOc>vyZS@9ZCpIjyx6*<=QG910UJX+M0Gc*IZ z+k6MXYuygJCsngWaJXCq{+Z@X+O6j81QHQAZYwh%QM??DpOySZAr;o&w3ZYY(QTeI zqUNXXNsn+F8?Lite_U)`xU{bQt<6&TaE)h*XT0uKFtFDoNnt;es^AoIu15bM3Ev zx>V=zW%Ccrm+jsk+PrOn>0ghn8?kM2w@X=CJxT^yZj;Fs8|7%x%oqlX^F*`jSph_H z6o701Aoow6XNuimwE!>!+EINZ$f@z4pK}0k_h0_YS?1sQ3j-G6qFRlg?grKM8{@px zm%_Kv9F|hZ9d=*Dou{7WD^;*O?stxl-;bNB?(JS3i@szpvQ%p-aw!m9e392veRi^x zxOjfQTL%0)ItqvhiNxi!QYaGNg@l~(VeUl0;p5|<646B}NM^=iGZROQpDm0)hO>&4 zQ1Nu#XBpc{oJlA=bZS2<4p6aS(u@AQqKo0Anb~lG;+V^xE+XGc0gucpB(pBYxzu0XK8T9x}J=i%VfGpp)_{jjVpMkM$c6_!VlxY^3w3t+cTqf2z|&IZQG zDx@k67&;@a?99vK^hmb!UWA|y2}a-@?HPNt`wY0rDxP1|!`r&Nuoz10Z#j81SpseVdm zsDxL=|JO;8Yh{AbnRyvxw7U!hpwscue}3)&2zUk>3eIHgw(@?WmPa443!HVTyV=II zsjGh95|@Yi@VJ-8$1=&BBn0`W+M1JPu@yo+SnNj-Nhl%lWCxDc&!RN|A3;L<-MJ{aGLhIhWyE{2GODc$8cdwDFs^IEYvVHcU8}%+kzjH%pSAyeLuJ zM*<+i(i9QQ!p%kxw1^Ko`5c?%Rh}3hTAU`eUb=w~Jo3l@pOm|csA%H>XOumP1$$z4 zZ>*-#ZA(*zQq<@-l&F5S)ZN=j&HioR$@Och9;D$JV8c?8Z8`fwX_9&cBuo_YnhWi? zkz&O*d_^%dKUG46i&4E?Bc$z4*N7)$iB(UK1=)&_LF{&OD6Sf#z;0D)g`I44)GErnGDwmWD*ZHQ4M=XV=h5ABuF7gK@@xy1E%AS)1`^9lo3e4 zfeKFd<1B$xA`fDC_WF9vZ9H&>eANe_gaxU$EMntIYfTk<7K)XvBY8qdN@KYNp;1Cz zt);L&$uP$c7$(A;5xR<>L<8beoZ*t=ZMS$$(+03&C;~=g%(nG5Xv1(`TuTLc5f+c zIGoYSIPU2e&sBVOfd7}y$|gE(38iw7@A}WruMpsg=AwcMYoBFBmk0;Y0jI)JqejFV z=40Wx<}FUU_-rc{uXAu1(Gkhj6UO-KYdO3Mm)EX|*#+>cNpi2$(aZ z+jB{^_Gf3geifI$_cPVndo<6QwUid@e;==5@@$SWmF#;(J+Vi2Rb( zrp&dg*4^ur{fru_MVso|U2Nkx#j9_!jV*1~i#XZs;znZAO}IG!hoiG_gu{R1_~CSS zo1X5T?smFOH`At1JKfE6$F%A0t}%?6uIc7}zP~@=?q2u)JoSE_mP&n(=M{t=>&|EM z6-W>p>F;`b<^Hm6*O(pom<@VlM?h*NRf>PBsfi@Sa z*>47o^MA|P%LBe>qz;v|q~?k>=`%&B0h)O~R~#cs%4#t;P_tzi-$S?FiUm>cH~Mk0 zGUIQ$v=->Ba2Xi8Op)A`0{}=sG-?0<5s?si)lfEFNU1}I$f(dP+jN{A4doX>c^H6M zV^ou;hhRqTlIu#eAw$twISL-8m?eHlHhtYp`J+d%(RQYnE?};6j3&zr9zj#2S%7|t zH{4gA<r_CXKLQlHn++SSCWz3UXL+`sbecKYmWZ2qwlFlX`wv)>X%^9L1|T*v6VQ z-DD6sIwziMqz9?Eq?q8;3bb!C1ecadreRcXs7!g+nI0sH=8Ss>pA=_HY1VbG{lXDG zL4|oiCsJo$cBNISC2D`ZiUQpjxVZlqA3i%kp~q$zGvOZL^633La>}%9Y+vEHy$t{y z9PjD`XlEGvaEr@~6+Finf-R>&q22&SF;@^Tw;Np0_+UrcC4?kc{)k;ta-et*-DpAE`7Nt4X zU7-xoTV4!Vl*@cOINd5kt5y0MC)OY)XCQV=rDqo~xwFCXSuAMO7Dx#oYKT4T5(6h@ zYa6X7t$sE;*`7m~GDj#NcF+_EDN8B~7(>An9waC_mN9j^_szRblA?_#3TC9&54_tf zOrp%Ifb2zn4{K6Je1gNq5QQ62V; z(@3kZyFtDuy_fpF$Rmx0NTRCfo<~s14q+?$u6k(cOsA}+*60X)2*H6W?UdshmkFKT zNmj*-7pmX!G5Dkqu$PQyfKIU&CwAj>a>&mzR003-vkeQcqN#PlZo&m(nEb%86QNh? zs=#VSsNhD!{4zvf8U-UwO$&h4r0S#l5Z=$g&6P9A{{ir^T*jAhfW>R%c{r7t5IW2u z(8#eBS&55(Qg@-mZTup!UVQat6rqL9Y9H$NN-QR)*201WDj4~$z6->aPqq4^sgly* z34_Ba4h93d1O?i+)5z$n3=09E0x-T7QQp$HTH%0m#Z_W?I|GQ}7o(?8S+6-+Cj(Pd zK8mhPTDO+%OihXHk&*FRu&F|1KGHDHLd@Pq&#T%Olz?;o{91ZdXt`Mq|B_Yx5ikb;5NY7e z{EGEY{jpD3VP;7M0#cHbg8;Do?kQSiTwExF;*^v-gyhLl!*Mm}kc=G;m00R*;x>FZ zRemI*boSf`#5|1>dY zOq^vX$GXQo-d0f~e3R~KJ;+aj?U7Cm23zAtWfIY#dR|@WW#q7ufVJX{XIS+c9y-!L zsjh15h)V0zOssBbynSklh1Z)3m31=AEPw_L_r9h&uVzeD(&NZ|IK^o9z<6Ga_bWYk zN_pdcHqU+ok8JFm$9}NlnNp3WeiqIDLlByGA;Sa!Py)d3Nq>sYDnY?|l-(ahC4oaC zj%i8~5f#lJ_@4>8s?a$;Ji2j*7JrWBE&WmgS)3>gsrFKoL`_|(U>0VDcS_*@l63}q z+9rRCjFg<1g1o<+-Qi@OFAC(yN@7k|pe!TfdiYQllTSsja;rs9*bwnkd#J6fkPNZy z`?JnJ!GZ0|TJAin+bS>fZ_w`Fa2*Rdznf|l(=^;08nycKu0E1UJpDxOX(g6pfko>v z?ezsSB1=+If(ZHLO)WkJl~tMAV2|Gq{ii<*q43$-n(z7hfT0Y@ zt4It6L8<2Z{tbVKX9bJLO`6X>x_uPAtAbeK3AZnSGvpd6x;rOc(6+zR!&D-tA3zYS zkA@>Jc~)3oK(9uv?D9o#LdYvLp~ANJMu}Zl8eQt;?H+RJRr~BP=kxcx$_Ux&|2PZ~gwn?$Pl+7lX&>F@%IS!!o5+ zwTXtc9Fahe1f*>^H*iub}8Y1S>m+Lp|yf`T=R3Thtq@;NjtD&orq_z%|^CyN+EjdWA{H;vL#> z?rma3{iJZz+L3=jKXNU$Tq@bpZ4tOSd{E|euMWtl&v`mJHs1e_pE(qG6>ZHXHRA4BikaMFYMm}{hmy#!ybEE!u=jv z%Ma@;xT`>#Vw~E==`vod!8B5?*_AC%X8;5S7%i_B3;z;xNZ{(;<%}dfBF(fTM%5L zfIES_7942LA{l~ty_cu-&uZzBHs{&5$(HaYcSK#-*i;2;bt+%+@ ztD9Ho8B~p_P%GGAx3Re5!JGZ(g_GXNn={P2i`d2G!U&;#M}}U(deU@i4p>ELVQ`fx z?1X==fdXYw&>^8f53i_bN+w$J^FlCnO_ROzcIlWWE87?5C4DALWI|;gm$e>26y9Rq z;g4i=we)pFNZ!Zwqu-XF1#-%_l1P{)Bben3{QaZLe&4=Cshru%zNM+5$$Y@_Suie< zhp!2`3EwT2okij2Y^3*sZSrC1iFR=L2aB`BEhk$!daZe!P42C@?0@{M!oq|9(;pKq zZ6;<`5!gU&I#+e&Zjy0O`N_>-+V`1006?5T%cJme%wv@BK`(abW?y1H8o`sj5g&Us za-v}Mo=Lfo-^~@vU|e>ITyySHs_mP=p6tunTjMzJHt`)@x(>8yjZ-%)$hvxuKCUVya-i$;yZvC^!awtYT_o+)gxSIwaE8Y3lV6VY#w_v5Tm6`K(Uowxbqg)F-K}w9wLkPy-}bN8 zF;PixsC*aZ{}>YZUT$MuR49#v=@(;^afEh)Q!y5EyhjXBiXyC+VDvy?ikeKJGSSZt zcP^|m-++hzHm%k8t&uMigOA~1*aBHD`3vW`{0S%MrpGZFudSV1^L2x0-qjHliXVx2 zVVKdn5{{f!os^MVY?$}zT)Y?LT1?35=tS$ zqzq~ZjmRRGv6IB$_1pWY9`~tBwf0_f%=^q{pFR2Af3f(MzN}NHMbA40_d}uNKpe>w zfFqf^!%++9~K&67By0~ zsN9dQ3yFd2-rP6)h#p5}Z0 zRit63cqTk5KoqPm$J$K+F)U@_xs7^6#{nU_n}sU>n>G80Pw9e6BF!$8b}><-I6dof zYIhzROCrHmt}_x*T{n$@DVt`%nX`7fb)oR{Z_pX^%M;(Eq+lp-WNXCDIHA?w#jrn@ zIIq1z03gTvI|Wbz0IS?+tE?P=vP5=1c(p1>C_!>CF0quSNE5or3W`>#o~>@dfZ@Ma z-g(byQBlE*GnftTH}k*UG$Uhrx-P~sW>#v$08xJ?LWiPFHZA+a3LI~Ky9Zos%hmo> zE#Ny|!Ez2puvZSPM?3upTO6WOi;Qdd-3c|eQ zP>#?Ei3x`ZNi8GsCZZN!;q)-qR#2kium{1u2um{!ck}AdYUA-l(|ESDFml-GKBfbo zCX4->-|!ISx#G@Wit3Q0v`gx3fmq9nnwNQwEM}pj+qCD(qanqzm8`ix2)+~2A%RPM zB;;jX0`Uq;!s>i%u??}R)61A{3<_uPjf7<)s?b!g182!3+$LkEp^w%{qKNm_txGdZ zY6`A*nQVz=aZ=MO2%eLk%%Am}S*c8{KL@pbNwna#8x)fq-}*z941hiQzy83&gLJgs z{a;+F_YEkN6 zoTCK4uGHgK++lWpHPP#_(;3RD!05F1MBbX!`S9F?yh!~Ox|aq$g6jBGrANLZ9xtYH z`<@pWkbR6gdvA!r*dRvrEvELWh?@bISdo%(3UJ9L$h7Q)noITp&yG{;NY$X>pN1;{ z9g657YwP}8#}zaNK__9!yeH=*)2c-TPX-TRJU#A6Yx+pk z;ui`%`gkMl6*|k^<>TXx^wb{VFP&N3kVF<7`NSf5!-Y)Ry}=V z#l1`MERq`TU>R{@g($nW4Rp!##C`g3)P~sgX*KlHFnOkVaWqL_J)pEG(T9e0Nxvq<{HFwf^a|}Q%q~wn`6o6Qsb&08_{wgZBb-@2J4K=3N zt>H}KQ&!!#{Q(fClvJM#la*wTz=;KjZ~_3b0P_JX;Tj-)xtnf>?4Se)Sv*aVN}dBN ztrVoyjX^KQUpluFj36(P6(#g{dHo7+U4~X$u>V_n?|16jbPnK67TJrf&N|DV0RXXQ zrH3fI30wn?_NrFyyVzG_+ND^)rqH`op;MS1gXau82|CJ6$0<4 zt?8h~Uj#xydF8Y+BC^t!t+T>kb1Zf6xJ@5BVtOkeL5#qq1p~`%zf-P```mc(YpQQE zuHa{Xxu9Zix6zmTtbd2#A-z}rAf9TgQfKUoO?|OEjKQ#gU=lDb?s?3MEemD&E7HY~ z^TrHS-@1g{`ojL$+;h}i{U-xJc(3!o0*C~^ZvakJF3^hRJrwZALV&XkY2j8S0`CFU~h%huk7LmDA!zS-de zwe+PCKnbZ!BW?Hzk_0kiR*8vE4G^UW&s<4{X+5FkvJ9=n@X1WYylI0Qb+s<%tmh>_ zzag~REcjJQD?q;gQg~Lmf7nl37gLSgd2UC$s6UIptcW@g{GJl(L*LjTP^fP=S=t{#Ewo2tW5#oy0=Jbs*!9SV5W_pI~#C1M?)O{)p}K_S<#Y#s@ut=gDs989DV5DrFG3JHuCkr7|t2XLnWXNh_DgLqL?u&olBBbn7S z|M@PL;!)ca#W#57u_I3rk`Hb)uutG2wwmjz{EE;qE^yg!;nHmr}yzN(HH4lPXkM0;Q&mr(pMzQ$CV6gEN=ee=QkMM z^_?GfxnynIi3*WOXMB8Pl{n!Sh)U6k$57f=90!ZJ52{q#J$o}f;~ZX0ThB*m!(F;< zo)HYI&w2{&$_d6Ez+P(>+nXl>PA&#Y2>RNv){pY$~k~}*ZGNNnUwKZKByz~A+V3-~_FqqLlgf--r zB>&s4Q=<>`mz|6eSl=33-UQbTFv|XykgNw*BzMv>)-7d*WGbp58ewls zZr@@swbd_*LiV*PC)=fxjCr9Tf|b^V4OYUrTr#$TL^Irq(WYQjwP2e-HZXp|jT)Uu zf~PiJEsLV)zf6m}A<9r_Hlth-5<6gW`=Kd_14_*lU{5+(RexKf)DN{X&7dKqduUkI zfK-Oc3RBB5HnZ1i39Cg^h@G9Z3XB~7;&e(*avx(Tm8Kxukv@~J_knc0FJ{?);{h#s zI*i$!70@?+#is@cSQts&IxA%4LYkUSXHlg@NSfJ4Gak(<004QccZ7hY5Z<4BhBCf; zFHOC!cV!_BoA@e<25o}H4%JquaGrk;75k=3jQ4&c!ka%!f5VCz4hn&xuaUF$e*I2! zbfHwfz5dpFi~lS37#Ouxu%-6CrEd=w3jpKjzo$14P(xe+6NbYnPa;luC_51`SP6xb zLzQkP(fpb{_$u-5nHz~DKi^OTm4i-!M>p@u{`%fjtBg}U*m>|O*PGeptB^X!QX|_= z0Y?|zWm#!`m^00A4?+}md}Ws+gSnYM@80O+bGId?+lI(+uPddMC-1E_Os(998OXmk zF-{tO*YR{FX3Ehre3?)1N~y z0x}5Hq;JxwS4A>w2Ahd5;_%Lozl0E7bxQ=9ONb>wg&rej*@NmA&CXZAPsz6sbD0+9 zB4BpryaMuYy+ux1;~S{shUL}zWj6rkO?fsXg&kipaz zKj@puVV9FvZ1wh^z-a%V^Pr7{L69SX`lnWrxAorj-rT z4nr@8rLD}EtjGrAt%pmh_yc<^Q>)=`o#A7NmT=90zFhz|Eu6rP+5qpiPq0g2A4#gZ;z^%b zKcJ9bys#j7w;1=QqTbU2WXd+PH%RytrsRsS?rocH$x5RZ7H@`;SXVhSD%lyppjA#< zH-<%VCrApP>sv>{zSG+to!)ReD_mU!hGXk~>6(x)GS55aGfO9~J%6Ju5$c6br^*CD z;mhj-8jW_SQ}ct$2TD83t9=)=Y`yIG6;au2e~NfD+}o!o;(?PHz>a*{1IGXH^Ir5d z(FXn1v!OOYeuA3rIpf6zsMm;i^^4ItG~eZIBROcdw3sN=%IGxloLG*W9=B%22zs(L zPZnSJd0EcT7J97?>a)QU)c$6m4Z5Lu-TUzN<)sjM-Fti-cl2RIkKT4q=UsTv0D)9r zz4NoEBw|!DuciU%5G@u&ZXj99!?VbkEkP}YXbq7>$rFZdoe(Z^-6WY@;VM|A6)Sij zlxo8&!h>xMHgT=z<|5Rf!;Y2=kLGc!z0nRIdlp3pCr$0S1yENh@=AF<)-^vVWVWMCn=y`G69ie28*$~Iuf83ZHB_tOkUdk@#IuEesyoHse{fh9 z<%Z(FHFW(;8$)(K-i_-*&eXu=DS(s00w96m2g1TKExv;X%(5kqWWWq|L}m%^HN_u$ z>v?DbnMBk@WD0+pB)>tp!|Rrlapp<%MBJe6-O{te6_2cMeG8d8%V4wu>c^=!M|z5~ zo&lmh2q}a{)f^y*Y%AuD4Mftw&G*Az2~L`ELn7$pu)j8DjLCx*@}N=f3o8P05#5_}T!A*^5G$kd5tulf2h~+N>05}Nxy5*dkI5l!ghK)XU_u6Lz?1$}&7UCR>rua!N;4HebF_4a?NpRRFdeJ){BldF&i$yW?CXZ-B+riaHoZOWh7-ZdExn^TujgWl!)=7LF+Op148CZ382UhxrOz z5(@O~OzL}i0+wLO463mZ3Z^6DY_c;F8NT81$(+6Q#5EY?Zz}ejgr#Nabg4B~^9lnU zhN-IRHQ!)Hn0KE&Y*`$gXiC_Y*MeE`4e#(=cq@DU)s$%@ip0EK)_VwU-LABlfFK{B zAW^aX{gG8)zC!6eiGGX92|Q}K9)TREZY_)PY?QUsBb@kIi);bfU@!m+4(|W@a|1?j zGz3lhsBBwRPD~3IuWC2=snoppFSxR+E`|w+VEQa?dVEhZK7DU+W#(>5hEz*PepO_1 z?)oZ@S4PR*#o!k#vr^8u839AQZ)^r9x@=Pw*ih#bDE1SYgp|bg6<^bG|Gzx_{Rsmo zGOQiVZIsM~=3_-$WM8B^@y@~+7{Fs8T!_i2649LCVr!|wABRp9Bu_}Bdu_n!ASV+g zE}zNP`7bD8q$3l;DN%K(45A*x#)ZZ*ld0U6WRd|-P5z2eqa$8@F+B4z?(ai=Qka^m zCaIVxC_HmKf42F}ljDzrD4KGM;*`;||68B;#PcACQlD@7_wCxQnLQk(6sjOBUxe zVR3r)L-%ybZ0U3EA;1)X2m?DqwN3aJ$WtK<3#3%{nL;W8MU+w}Mx-69ecCSGmA0I@ z1Qv|f?RMXxd2`a&2h?UU+VPo({*A5Q)3~GUi?y|Tu!|7A`BC;H{=UEvBQ065pLVc6 zoy&zpCFf#Wy2KXnq}N5Pjj8>#uI{6sps-iE@d@2XbTW7TI(@gT_2d0bdqZd3fu9ew zH|WvbuEfoDQ=a!|bUs@qp3m9;?oNuX@tpqcI=00L2?oFc1MEXGGNw#f2-_k3Pk&BA z;CDZ3I0#vq8#3E(3hz1UpgXL3j%JGedMN$R7aZwW%2o@AOV$H-S7)jd~?K<14 zMnjhH+B-Ej5Aw|u$62iLu8a8UcvLj%I;agbKq>b&mgeQ?!?5MeaBhpl9 z6=K>PTaqKU3Omou^0K_ZKsXEs4T+8-C5`TBZAmc?ia_(H)Xr^U-MF~XDUx<@Xc6Yb zzrWyf$Ph8K=-M-*ivB_Bis!!eYNAU+kje~MfCayY&D!N-Ofty6IvEu#@pvF>P2RcB z%UHMy*av4N{(JM2e)-!@UlpR(ma{cxEs5N>Pt?9je}2^$`l6BwwI8&um;@l<0p7z=E4^|C*LEz!q9)p*#M`1c0vBb)aqf`GNfl}-Nptls z$)n(-H6BQ824~U)EjAHeRF+=R)uhwVo?aJgSOp%WuE7fH!y+?{aF*NoG44d0k$ zy2f~#{hq85KEEn-ErO?-;jyHsa5KqgjF1H)ZVI4;O?J7(2>od#@#f2+dR)Vb_a8q; z0QfE=jT0fID}&1DZV@XJd_h;GT8<3`+dTW`JIe2PbESDc1h*WlZv`kcc})|N5fZHn zgO@SXM9=32UxKI6Z|!Wo?{5UGAK}rL8{1@HvD?^<`Irm@V`^+{}Ol@ zaS57CvQ8N|d5t1H*OEN~$_RRbPZLv_q-^@q%-D?3aDtl4R9jbmJr?)~17B-Oad9mw zy^n##71z4XRZQYQJPNHR*)<@yF$5<#glcG}Z7p**&S->LT~H~TsqVcDZ^#tSOfNUY zdH8!Af;Fj+*XNFEjaH!7v;2{T(f2XU&8#{C^49%Z*2LTIx9fFQQY87NgRc@x<5p*P z8sx@`i`bYr?(5daFJt+~XdFy{0sw7$4}~T;Sl~2Vpuv-s;fLC(;|HB7G3UA0vJ`Pp zyv~F~C%i`XN@$7DU6Yh|4fW`dw65^w&(_h^3Wfm%imzj*6oZkgGkND>ktn65xv=p8 zX&@C0wqsS43v_f!elLl0|A%10@TM+A3e^2ltE6S1We^A`|5s z-{~&=B;uupq0~hMAC=Z5Mi75C=tWJ^s`6KvHWBt-bzGd=?uN9C&Iz1X;PB9t*N2ak zT`k0uAjJ)Z4=dqZuda-@RhyVQ7CF`#qcGvC^`dQKCML2AOjLD98xmTo(IrlQq!Cf( z^iqdIqx1-KD6qvX-+wP8FLffMWG{0<;c5cs_V^nAjDo)ca@dqw74ucE;(XY_HH-g` ze-Pjk-}lce^HYA_6N(dw`ifgY^LVK(pEBrWnemX)I9cww&DOV5vx z5Hyh7hy-;UBN1^j)7P;|F0aTADNAgPZH47@c>k%G^wT>&CFFt9Pi(sCe_G89c*`L$ zGZ@qo3x^FK^;@0ghRycS^lztKw>L+x=HIGm+mh$o4i=^LH(nJsw-^b%oiFz~XBz#* z#K5|C&96ExQ-H8w4xoC@Alx`7jH)jQ+Tx+0(pjcIko|Qh$ zhgTRaHF|_cS?j@85oi)d$w(3CGWd_tBta>!B4lG%d|yF&24}XR6ze-#l`Q;f8tWnb zd*2-_{yv<+&@ejQ07!vNhF2$)0paFiF&-T)$P^yiv^@%LT6SO)U6e1Lu3egLX1=A8B|A$v zf~I)0|Elmbn}V&Lx9PUF&pr5qM#oHT=<>2_^}%dNq{Tn$si#foRNd+rUrP8*1H0u+ z+W3r^VbdO8N6ZmzPmD0h(nPHTAS6nnAPIm+HtOOm>lzZ7AgOP6Vm*rW&n1@~#p`zb zKYp&k@a0+{rFZ|A8JzvB*`6o?qmxuk*BWwqT9xn}HI^lso*83t%Wyj;4Y`~7nIbqEiqqd6&4HA?qbA{o<$^|Fa% z&g);1HoS^g575R|XvPCGv$`XKHj8n8ZEhW4=J(zQnc-^r+%(N>bR=F@ivAOVsM<3- zlx@KvF=AO(7En>^@kszm*-=b|$xagQyvQdSa)iAjOUic%W(>q>=v5C#`Mf7r1FoCq z$mod{LA6);!pi@2U>aDh=c6Vd4Ri53Q3pLbXdYbAOuWj_7KDJCwNmG$=OPj8zg%D3 zS{uN%$rsYH4A$|9Fo0M9z+>cLxKD*DOq-IH!(t>U*h0xee8E2+MINr_2@uP^?p60Q zveS3VS~8hnPh&!kmU0XqlX>jpQ87-)H8oPyBlq{T`Id1ec1FDn}0bEIk|&0f=_B z5Sy&hyZWI0xdhDr=<~>|j=kI3+@vek;Y3Rupm)OhmSSA!In=AzPl-(rk&=w6A1oiD zZY!^`2y1Ci2{)1UiNOREv)dBd{bI(lluacyYIHo&yw-*% zm&H*i@2GdKnlV1Adnm)%Tn{oBEm|>rcpVM$d_5}@`-rPhNlJ>VvKn02IEsY#<;;?7 z(BimKsGd>fKxS1miB|l59Uh@f6q{zhNtAlw!Hu~Y2{ZuxzJszfS++Q6f3KJqQ^hQ6 zp=$0)L_Vj0kvdtW_31GGjni3H<@}hWUT;Yfr6E7vu(8Y3Rs1rOuO3nbQqVoIEt0)Q=kve$WH@&8Fj-*H@_5zwkDonQ;F7k+38taB=7^$#NO&fJh_;9d#|EU4h4JPldj73(5i5^=vv<2mc$fHg zhHqruOQ#bOAydq$6ijey&Qd`+DolhI=bTpUtcAjA)$7aR7TC+y7yk3TOYZ}m^ z4UgmUu06;T1P%LT0YpWX9{CUnZ))?Lo^4Xhj_|!Br3?ysd;_6S3eVlEfM914?TUhxe6t;HK%ZZT0 z=@q~6GVO4z2_}#pc$#h{*r^kGITdEH$9k-79{QMpdGx%& zjRVFm;GdHzzQlX>45%q`No69&kru`)Qq}yC#?l%6te)$6TGb_hWBArbsvuAJJK#d@ z(nAZ%k=yrq=C%2%vruMpPaVIqzeP!x{>97|VSbc(lf z^37XSz(^Cnm#&F;oW@8V5I)`_V1L6f8Mz2>r4Yv)Hr8ukR z5%{=3g8m=>guvs0KqqMY)K-y-Dk5~v_yL|0@9}eR3Ke7XZJH~YJs#Jw-;!^VY3%)uttvhmxUq@p`$SS)rMGQ%!i8@c_(Uhis|ZU|@lP-^haDCO*0a|V36GqqgGT(*oyUn%V%Z2zS1~Q! z&|-P2_L<6NMAE51yHZKpjvJ(cj+1T-EDg`jtOySaswOCMw2XpwI}YTU*?-@9LXvLF zN=8=lrI;M8L<(tAqZ5%k{e!(x+j`6Yev0MaT94Ol&BOT?s!ejoU59aXliDe9bS9FE9}yJQ^2oXq7^vY7EE{S-s)Z1Of8Jequ`Ie)6z=?}WN2GD3U zHI2O$&3nHB82}36nJ~2iYG`8uW<*w<9Kk7ZNRkB$j(f^EXay((nIgE(96hRjmT`=c(|E(CFtt9ofP6kriZNuK-*Z63tEz&thr@{BcQyYlt zH4zK&;9rhqO|AH0WFMwWq6PdSIoyAD+rfog3>aaBOr?f!06&1=ok|Qr{&6zWjO%VAg>kuY7E&`fN|LXu0fD zUF0Cd5H-N5k#iVxeD-b%c)uqN`;qiQ){0?Hyb6COPqA-hjW|Xz5rr(GB5+w6!OYF> zGQBiEu)tP>VAOg~W5g2A@{HMutny(b8#W0J5f?oe{h|HnTebl$qNCrWCtCW;d(oOEAxtgHlUg*PCcMMu05_*6WzaP>~xU&z8n z8{6;T4o0FlFotn>*Au+b&}6mh3oD&R9ih)m|3sm`;^q(cEq>am7d|Mtz~8YsJ>TLM zK4*XG+s8p{QH*X+lOh;p2UMhv$S)|UK58GgzG*AdX0?xJqCL8V5k9G0m zs_L%PxjyAGU$na$FFBqvh%UyYc>n86%i|7RXSk)ev>|+%oe;xYRJ(sQzn;Cl^H=Jx zTSrt;&a^z%P`4{}UPRABYWmEr|E{V{>{5LL>ZW680AyXSaS}UMd)-^#U|AWKMA=Yn zRMpkf_ob+pG}5lQZcfp<#uM|e1PO3xlh(CGpP<W2+XW_RYXW2*@cH)H)-G5v>Ju8D7 zB=xUC4}QKr9v`Bn;Eeu~{YpW>_ZN-LZlx-NMMk0cqqgdA7PE0XeHna;RdoCy1yJ1$ zc6BsF`hePu@<&qyW|Q|Z5NJ;c1|YwP|6ND*IEaly)v**#K6`&1jX2}2LdS<`FZF29 z(`B%JUef4)|Ea_!TUTj7#-KN1s4rkvb`>SD+67bmTe(%>nvwoQy*|5zz}M%FA)pNM zMGSO*R$Sye9e0?oP-f%g7;PFKButqx(ORs1f-bEbQ=*KjVS9{-yz%mN~cE}~*Z znR=jX4#TuH3V^{86GoyjM+u=|f40rWgbnk|nt&0Ch;|lH8cN9;@O9W3nCxU*=_$;}eM%nBOa6Xs3zf0O zKSOAejmc)qc);7WO{b2rF8qs?h@yH{33wUq*owfci>R0U3Hz`S7Qa{R&tsPCdaqSC z$fL$a(K_<%sW4G*^}%Jb$c57l7H~JuXT=#(T)DnGPkF}~|IOg@h?^h)Hp8Vs`fDi7 zUcM#88nSr=2KRY(ROT6bjHN0mj!+{VDpsjeF{!%Cu&W04hC>QPi@A(94(x3;>Z zyFDPHE?fha!?Hr05)iw^0rne`h!r_R_lzzwMzO|MidSO|AB`-Osz8P%G{+4Uc(Mba z>j(-R3;i&FED7Ed5pz`74X|!Y# z`Uba9i-f2C^wWcC?hPLykh=aeG#SSrda6V`ZiMSLJ|Np``1IzDn4 zGa>fgU$$CEfJAoh!o&0%4UGb+FA)o<3gQSyrAG^w9F>Zq%T}Pjek6)({usd%Df=%XjUUimgULB#`|Z zcSR-D6x{p=Qrp{V15X3`9>04h^=n{kIxf~%fSd=%zMsx3z2*5AvEE8AUyI(5<)o|A zsrac)lOHo<1CG&E>5^0nPp7dx2a7i6mwi-V0F5SGC_E<*;!uZpk~9EG0$YFx%WQuV z2?NpYJe5Tpg$JS40gs(hgnafXq&jaIf5&<_G>|O ze;8bUHUKDW#d2dGMWS?q62pD-Gp3_)OnP^kMy*62W2iNSYCQRvI|I=(>D#Y!Hhoud zc*Hrrs;0d$75dx!#AlTIyu7oS{MKJ7+@iB}$9>aO((NaecwXLSb>~X=>l&EV^(w@Z zZ=27^G=}MoraSCagRJoxj>i4s4DmBSOOS-2{pwl=5AUcz`lL8VA|T7ilj z7a55k5f+3B#+*7v<8COUn(oibF`u*3dV*L~l8R2iU+r{@LQIvVCWh;q`SMzp}V^oy1PYT=nhaz84Qqks{YLzXo2|wT!)n7kRPZ)`?|7b4N0oQ^aa}=o?9to#fEF z>BK+G?=SIBALgBX?%HnB#jdnJ?tR)h*VY%gYWno$y!q3a0T?+x_}3CKN(Ggh*|S;v zkiakd2}fLmijsph1N5p7m^=?i|M9aELOZIadO|OUqZ?^RCPr;akj%ir2K83Rrmf@) zPd_Ze>dRt29%s@ucddRqp=D>iUzt;GNK6eSrSYE54nEhu7Kn)DhHT`$l+7j zu+YhnRw{mdPo4dD*^vG0a{;AF7y!T{od(MLVlf0TCZw|gztpoVq5;+MBk1*q2YfKMl&5F~|8Z8>k%5 zIu)=WIwM;A4b4EVD`F!PbSXp~ZXh2hJ)C6WyuLjGlw;Ed00Yt7%22eX4Or4sL`%lP z0{HunBK__DPTEPnbe){Y*v0rfWVcL{+-I~@&R6a5a5r8PYd$YN=Txg&wcj1gW*X$G z;(SX=(32*>^^n%1jQ!~&=PZ&B2tlBSa&;f zv6$1(fK2Ougcb;j`e{Alp)p37aPSGai8O8Q557I4=W|qB{2i{JZiAy@CZ2*shB&u zblX@yd&SFnmM=d$@7Q24Hrd!pm$ zD~=+l3{sbCa7ezWnY&lY&${>x!%o9~VSf>PpT{x{+qtAkD4)b#oc!mhshs+KX8Ocz z1^+0h-pW=~DiAKO<$YANQFX|df|-*mTPbK?i24_UPYysla|i%{XoC*?$6WeRk(+WZ zXo!T7i)di0j1EAn@Jxcur8ii1Bs@l9bRe|xOpJkr77WwE;F(^C{KYv>fskYC4abK~E zG)SS(=qs*zYMYi?;8xsxTt{-X+4`CCO+8s?rgF`;7fWh28UXkS2L|O^Gmo_oBAqF( zq?in8fH>CbCG9CvUJyP*|Z3q3JC7M2`=?Ia3kXsV++KZk8&+G?j>U?QP zdtrNhy>jbnU)jH=kA1vX1=OjlSp9rqJ?>*Q>-OJd_O#41B#ER1AcBFH!P@5I*;*l5 zL%`5NF$s;rEy;e64u0nV>_aw9l;ixr+Cki;KSbEeq(l$2-jxi-!KTZXE);+0k~@cz zDIt`Civw;9vBr!_)}-qZ^CaYAys1$`Xk|k9i`@msu~>t~{Q8BOXBdv}N>kmuWk|cf zHr{U8ws9E~LYh2;I_zbcau4in{c@k;UMG{QeM}ry`fi;0t~0a9_nRH3Wo!@zG(kl8 z?mzdDc(YTI)QQ0WycY~)>Z#4YY6mIWL;vx!|JFa$oOVQ44R#sJK82o$<8KPE#Y5u< zO6hH%BMbi;P*pj!s)jBR5(Hj+r=443Hn_j9LX8&+(yK;h{hQ)QgzuqL10CUco?cS% zgm>uEa!igUo0!Yn80Ef;fBihP?qIqb#Q%6t_zojrXD4Fkkto`OaOuB<9)K9kO`Vs` zo>lnyQ|=JaFnS({5)b$@OO`n%8dHlJf33Q@b$N{3$WRw4DLz(nxlNXa7pHhw+P1l+ zer+StS!xg_;MWU}OA0Li1;=T8h@1*Z$82s*Px}V87c)@9la&I+-K|N6e1&K8*#WAK zi8IuhSDP<@SAT!)e(213!Lk#cZqMiWIIfaG)*^1tdlr(KDkAplm?PooDBV5&*ix$6 zr9%d9x>g|semn#K5GAsg4U4&x+gtRZ^JNnFYRjtqCnXXY!Nx-YyWtWY1u^8y`^ILZ zSgJ|Patu2}J;Xm}cVF7;!!7O@BwyaPq|STazn<6M@->N8_)}y7QLu5y5zT=y${(tc zLqdx=EGa=fvhy1_i1_s8_A!iNBt@MQzFAaMbNs>Kq>@7>CGH{pIghm(=|5P@Dn(`< zD`X^aBA^<&>QOSm(V4?bmGks|OZXfV2UpmfliGV7PEWzVR!!D;g_IC}OlP67QTB#{ zU^wofvJ6?OP|o!^Z5hl$9!N44e->ycPh60C4pE$a{iyfpt}iFZl}~%M+o`te)KMc{ z$$2Uxv#vP#c^hqxo)KGHCkg*QeqkawYAP;p*c58pN1sCBGIaM;=$)-y z2dqfTn(QW!E)heLmBO5oZHKy zMdrf6%)p>cf*3epwrhY9>W{wZD@Jz^7r~+%TvCikB>i(_)#ZD@fjdU|(cdKKK2BBC z;T!)EEV@`dQ)(kaRV9bj${el#kSb_m@|7p$udxdqyryO)^mgyh-JKoF;C?Kl&em4y zbDr}pdajw8#CDfQpnpJqvTxEB->gZK5%R{D&U1UE7E@@=U_VrW(~GOV(R60@Zd(R` zD2Lx%9R~>b=Z`zv+NtFv?agq;61u^i|FT(9510;(Z5M*(v?552c?ho~Z#4sl`t-lg z(xle}G)Oim+5U@u|LZU5yYAlWJOZ*Fp3}@2-ySm)mU8SA_K*;)vc(y>snxvU5Tw*$ ziE=k#I9753WWMG(@a>l@Vg%Glx!;YzOgPpHWCdh(NnDRegPr>9h{Crdi^vry!c>K+ z=M)^Q?qE=oD_&~_dXtR7HR0U4DtyNbz#%xcUsIQ5??enq)g*Q)9vPstR`0WlqmavA zu?#cg9iWwiY+A`rcz#wvliw$*0;L`ksD|M)pXrF-)!*3wrg)MJdE7NZW-_g0{M z^Djuwt+M%dlaWPQ{tme7l@_z?r#8mJG6a#!LaH48u24lzF_t=H_>2PFf3^IZE{)Q8 zboXm{4K5;kFcAS=^^5d}B^(W#OT7{Nw(rf3H!oD!!s%%fp)=MAWWRX`u1UJBpzsDh z-3f+(FEo^Ijo&~BZVgP@?#&_-?4h*k3`fM`I5=H|Dt^lCGbbYG1gDAaM0fcQ2PBKt zQcOp+pcMMA#K6r}yInMl0=Y607sR-HaP6636_b{9L(AVdSHL*%OpUkmmB0^&2+2Q) zLg2MO(E;P^{jOK<>HAwFNn8QmA;Y&_$Q9Hi67PC0{&QB8BxHcNIJwPnG$t3ZDT3cC zdut+JEk(9*>9^1#)ar1oq`uELV>$y}>RBy$C(x!6-owtUzI?j+B5t8;Nj@^8BMF#! z&W|80P^*<AqG#-SEh~O!A_EaBg=pGg~k+&3RJ2voeDxZ*UgyYb6K|!XKW5DoBK?5Z7oZ zYeF|&3r%#f9|=9z3pDiMB9T|~{Z$VkKlmk@!!P+CKR@AU->aycU`hQpw#)7kvv660 z39;2~#g1pumXk#g%&GdkHg~)CCGYBo?+opw(5y|DlsNIBGsa7y+Pfqg6^%t&1%AIp z(KM?eBM`i$;Zc$CCYJg3ecw^k0RRkDo)#3M>GA|cWSu6xEm(XG5)8gi&mV^Hi8{6NA8npUGHzX z_{;kM#0UTm0L5h(DVg1eve0~Zf)oM+k72Px;W(IuXljBeq*@n7d`!jh(#r>tqT?*3 zVxG7pGe)oW7?#qzRpA!uZ@W)_kmgvTbq=lU3n?!~cV|2ZU>ih<7gL1iIoJG^R;yB} z=kqSHlA zj{(AzZ!c$`or*|A7pqTwJcrEw(>a;3AA3ithz(}-_>Z59U_>DmxzqsZ3v-3&Ix*^2 zExz|$wPk@4N_pkC;pu+p(LI-xv}GnA(~2DqWtRNMm9aOH@U!jjH0%;LTw?lU+b{>r z^j3l=EWz^?6DKXMbSnVh5Z*`O<`N1-51p-_J$;B*2%@|6};`Gey0SQoMU<=ym;h`^R2gjcxT=D^~MWC5^H+&9`z-{VIv~t6k;kF-j$|4^zlqqWX z*Vu4214`y?DXw$*zc>mpU(7H#E8=~W#fl8H4@*wW#NjhX~7#b+t!6r`S@%%mso=UQk2mp7F z9vpkpPFB>I5sNQWRI2QsCm$w!kwSOnYG?kz&cpOEmyfaj*T0~QNg12g1jm^kzI=9h z_XB+3xAx|n^tsQS$J5SyQ_kZKk8b#(Lz`syn)Np{bw5?J5tcM(N~;KVMnNE=vPz*i z|Jo5aK(LLWDKY|QQxVntZc1uNZh80QL@`a#VF|Vm68ee~pCW9|f{A3>zigXZXncVU zwfIbGqH?!K-c9y$NHV@XI*=L!rX?L$ULX4CA14iu8mZ615{}?WU1lofbeYbxvK^sj zXd!$ex76mr^L2hkRB5{`3WdThYiec2+MbSPdQ>Iia!)Uk-I48eNIwN!{d#R8 z;r;Bc>6p??C0*woO@gdM&fJFG+j~@~)#4Napv@ib6jc0=pPLATag{M#f8GlRRN@Bc zUVxqs5Bc;S?m$#&xp!D}*C8u|$(MTR44sxrG4f^I8vuAZ7sUY~-t6X0l$zoTqlJEZ zGAk!rlx4q^-JNjQ%Gd6S&UZxe@#?tRaSbBlGJ8R0`_*i-Yv+-+>&Jn%5gwm^bChUW zya_|Swek^lL5t(03UEg_F;Rz9D!0}$VNQWbAGwqwG0UpARcqk!D5FNf^Z*G~jEQ~E zL+Xhov^0WeyKFoWphn&{Tb%Ovg;NomNm0nn6^zT#fazr(LYJOnXhooO+SH zx&(`f_joXob^8V}h-;1R#k6 z8K%Te_iunw@+)GsnChpWJi954P+`HrmK>fNTdVST0^|^%OJsEYdxy@QEMw|UM@l@Hg;)BO)xSk zr}`K~a$C9WeX7hha|jA?5~vAW$kc)m-VYBPwv~0kMdyzdr$-=QJahmRVqQB50;Z2F zWx<%{G!c}#O|t~!>sM@@BQii}do?nP!FAH4W;{OXx`Y2TbCMgxP(StrPXMQKM;Wf|dSQIeNHWh=#wD%-Is!ESyO51o^n!~Eu8uTB?@ASKym%lvV`uKY$=u>E8m$mOgC33VbpS(^fpCAbHRI z{cxK5uph$yY7Y+cU?v+ZkB9CX{*L%cge#w`gLMhsGWa3$_}DijR_?xkOZZlK!EueX z7!dH#@`wVdWw?KD{q_W+W0HX)Ffe1mRbfmSy`E)KDc%} zyu<{}132GoU6GnB6Qo9ArlOe3Kmr!{s0kuhr5I!1jK9!Q zZr2x7EQ-?)*)2mRL=nSR+x`Z{r3=E9{>_&U6n%Ohp*Of~?`<;A|M|6THbVzaX$&Y5A~RW+JP%RODig9Bq5biS z6DtrWd_FHAf;OYnnHCyjK`0~g*u9}W6&FP^zt&H$NLao$;gb=SwKXnXRz>^=t@19o zfwQEkFveG8;sV-}?lyBL>X*!-mm5|`!evpl^Jw`a;N?e)ndWfq!h<+RhKk8VpZNME zw4xNY1#EQIPItL~eSUcWP6##v2tQ#I1C!mojhlQ-F?kCA?|-q6ijXKFC*}XK(3oMZ zLChis9~+;F4SF*WN@mj*S;W|UKU>SshW&V{B)h-(V_D93pqT;97eUZOL6nYLwhqr* zr-{(?F(}~r*9BF=XyD50IE>ATV``{^M|GZ8`CBKpk%U4fzqP+T?M^j-`z=)lz-fb2 zRg5{Jn#)TIZE;b7Cc(+&!QsXrlL%0BQ)^_F4W@wt+SXH+a^RkhsVA?Fpp7x?)>_l!8=EJS})I&CSP(-ZG(p1f3Y~Dy}CH2M&sKN z5Xe_SYcA>Xi4(Sys;*s}B0O%W|_ZX2xakMqQgxBD72IBdYU4!fKR zecWR}rN|aQ729uLD3<%!%z;Gp>wRFO)hFMjpsPOpgUZ*L+UqzU{9cc)OLIUl{19>N zODb2QvKDT(HIN?c&ZxypgaepB#WAI&%B^G}1fYd({|SURje`8SC^dr*|1D-PE>6YF zHv)&RVejx)zi?n<6HwxFuDMdtb~Mxigw<=q!50$m*j*AVS=l5(b#x73|Gp3{x9Co^ zi}!mLHMXXiBMgQ=t*X3BL?f=N_KBw{fbG zcD#z8kPWXlKD=Pmmv5W>nhpnxlF+0CFx+C(C)m^6azvU%(9!(&{~W?0FlvrF>KnO1 zBi9O>cXPYW~4((+0`Af}DV1;LS8|=yIfzZZSHA ziw33SB2?Xj_#+AT3t7$cX7|YfQiG-8c|wr(Z-tWLjao(9#B=VBuu3~eqiPyU4TmRA zsY8G=0Dy)bmKveF5y>?y9gCTH6b(|gjt1z8*tyFwtaG>0TxA}np-8VmDX~L}KokbN zB^F$aG>MrOQxrbEKNzpFQH+p3f{Wf2+Vj-*pHZiFxo~bm2Y{7a*By{z=>Z1?^Nm|+ z#lN|12ZVk?DT^@f@0YGlDivi#f5S-|l!RX@e3?n*|0UQs1nnm6zKMW4nIt z4z`kDR+NV$|IG>KJ#mt0lOeRE6WR%1Bj?s#{ATEbMy zQ-Or;Sa?S>*UkKCNTpFf`R~umf*8 zYgYXL&Wo1u?O&B({JmfRKm-R5P)AgtS(ChQ3n9ipa`%Z&>72@tX8*avmNu+2iY6y?!ZYH)yvz3f>)9vslku zNZF+*RBop3%YLnS;!NqnzkTSxq)8zyS5H5*WX)=AE~9ml^{MN2uwc1+8=}5_J_tu) z37^&y#>o|J5{R2@jPc~h2{?W;4#kp6L@Hi=cz+_vJ0@Q!fM;bNI%7l0WMa1!f*fxL z({;#5t#$UI%`qJ>l;O28_Khh_n^>pL8VMj-UZfAh@A`Q7s`Pc*V#v61i&|Y~FGJ13 zVk?_c`pN%323n=*jh~n#biYizA>Ki|=3Xd|`(;_gh9sT3k9}IpHLk~*FpZXgurR8b z-vNkElLkZt`aZKNB-fNL5!LyHh8Q;nt5Po^yyToJf2Avaed1J_uUmQvRU-Gqz-s&{ zwOx7Dp8v!`5x|v3ym_iVp?6?%5pVl11r0=tlRRxLak?V6)kTXfP60uU!+A?IUp%TR~845mxL#mm(u#{0XFP_>%V_ruu!4GS1BYw| z_;P3pck{rp(!x#>w=vxE0P_pVQ55FF^5z?J7VMUGi-=h?Jix9AjOsJ}05#75PO=~~ zKkZLr)sqpScWEN*wZels8Z5gH@3j=ozd5OMJIsCYeE-@0?=8P~#?K|U&GX)_zAxW< zHyzlz1H|N!2W?_);4RZJ3@>%aw2O}mCbM$oX@X+__%9f=qKVQLlZNYj^Q|t3(h)~iYmo+NyR)-tK zLFDNeC+KL>bnSNOol!a$?KH|{bKV_uf<4*7Eh+``el-SnbE)&0vZ5Rv@r2E3%}(*= z7>ajLaYs7q$Y2Wp_`4ci-)!DDpSm4jL;vz@{+CxerBzx~+;C-{=B818Bq0i{ZX#uo zhy$jB>&Nh&Q(cHmSSq@=n9_~S?{yT)8ca0(n(p909HSz-iiiw1|K7BeQ^xpcyVmgH zWawJ@eh=G{;o=eY5S@VdPi%7|;BO^K3u#}DNbdnX7NaTcPwV*Cst8D6qJG>wA03Do z#mCJ&iIL$bw7Z|--=Wq!1RZh;aoN$N}OUn*$g z+g$rk$Nth{zpWHSZToaqR4JxjWxn1`)PJrD9DPr-Gte#Z@Ah-#JGm$C&h?61`VmZ5 zt5GzP3gsWozZY@216Wx_x6?iE%YQvdp<=-Q0qGPgYFQy5=O!Bx9Ex#X@BBZ0#ULOH z%US9NHX7S1#J|Na!k#BErhW220vVlm1l3{WVunqv&xZx9dkSR8M~%gN5`0+(?z@=> z4?1owYP?y|-4zpp0L!0|nkWIL)eLm0n-MD`_s)|a0CT%YCPUlDbB4B=Dhz~Vrk`J5 z%Rt3%JN!UqL5m_-8}UE`W#w<(P*n+?xGX$z#FQlDs8B#f%!RC-wxpqCqumc?dlm)J zR}o=-YHcgXD(hEAj_GcuTqdqUx|(sgKr5k`YvW)au6SRjD^BAjz3x4{?i2^e;ET!_ zOV&^F2?4e?)-zszb4`Nh3AkAKriP=t4^-$n=@aO zyAL1p!$I%PL2>v>XBI;&2*bS_pgftW_+oh6G$iewKKMYPTp$$V=d!j31W?XY&-8+q z^3Z#driqlrKvs5RQy_SyuyKfTln@Z5xZM0V7YrMkc3^=B*YEb5d2uGMa8$*{CB+zn z+wBfF6rA8)QKRpX^E-rSKr8EGUc+(@D&9}R#vPunWyxPCEMlyjLI`BI+N_G96jX6gUu?*{Su5QS();bkj71feoF-To83Q5<9G(YWN7Rg2dt;}K zCi&4hXQ(cBu?K|=)ReEUE2&@tcw>bq5k6^BrZ4+!t?86+8Pphu#cOiiM z;@l$Bnyt8cQb2{am{#5AH#Z;iSS?HjXi|TAn9zP3yv5!N%{aq1>B^f?`DEqYt;rA3 z`f8Va-~BmnHxeO#4kPz8&%CCQ5;gq0>R(=q|I9k;N-IGUULt)*wK!`YGB=ZH9B8fZQYUyI=Htj6eabdj!ySo zF7ZIkZ+gOmf16IS)cCQCyIgSs#-%uN?z|T?a!qRJ0zAu0$JGUY2r#<}ekJ_&|M(RG znh=&%@>lLRm19y7v&$fGdd0-pONC_n~!4W|;(voAjg<}TCarv~U-)I97X(3?P0!N7)WLPuzaUl@EoS-B;5%%x0 z;CC!ylF;xZ^AU`8g|-{sDL=5{{b9Sb3<*l6I5B=VkL(sR4?=DnmU}k}(980Yw0w`p z?&^hC&)`?`)LH_wHEI{hCnJ@gFiLI0u9*)A zio;HRtvvP)XEy1TAVh#ZIxV@^mxv5$`#z^req&QRTz=#BboL`ZNM>_y&-Um~4b=q% zvd;A~7pBpxrU|K!f2R_BX?Sb!ZP>u1Pw$i%9E@$gdc_BHXu!q{2)?s+oBbunCifpR z;;Q+clGq#=jOP;&DC$!QsSTyjM6$@=M`-0blD#%c<;1o!u(G~qJE@+}gCW`>Vn+qb zW|1*_^|7aMLtwYT84>#JroLtOUmFowgCnQX3bKUCU~)*i>y0g50)fNfD}g%~q+!wf z-{olBR^zy^I?q{U%~-5bmbBd0y*N@7qeZB{7*fQXjSxD0y3nX}(TXceAMcvX32iI5 zIB+=EpF+igYJNlb2KbI_Jkr}#5i9L#liTeLAo6+72qZXls8%T*YILN`5W0Gf`H!Eo z7=$9>A@~3*H_HRTBj}MS0q6t^{hU%FpS09Ff_ybx?Y-~1F=`^_tr*@aBG&pn6*_}( zch9Coyy2geKSBcSMNIsCIH@3>r!uY69QTlefVbIe`d@?Npqpj{Ldu>6mMYDmvH)l8 zUA69nhF7gevH!dYfB=v|6u0Yg9+Z*>VURa;B%C(u&{_#tN1%9_jV?Q@A;*+L7pHE}nrFQ#E2DI%@ zpG~F(5eyxzIP-s9+fPD@Jo=@k*&V)9y4Mio$=dMx-n@TC1_0walcs@hD+mi4hlR*A zm(33qa$P&jpz-0GJm#j?rr^ja%JI%K67PSay9}%LluwrWW1pAZ*}V>?XGLDe*zVx= z!FeA#1tv;PNx7s^(vOkd8nh{_=m-K(pzQx`I4h&%j2Hx)Z7Gy(o5y=Vxv555`2UgW z5ESat@7d@)@L~G5T{~>uIE^%a_zEhFRSOO;7!jSWY7AxbzyWXu1L#WZR)<&fm5w@` zW?30{NrG%0r&cU^n>JRpRU82+qWMX|Hjt72=U)HAsm3iBH5ft*w)E;<%i^CE#uhRO4bM45LfB(1% z*$Wah_EjWMJi;la)=Y~d)m~j+vjBc?!}V#t>tWnh_--tzFhGAY4n2c{w>%RBTOczuWLNf9t0G!NiSj6Z)QoM`uGjLVto3Atm*Fjpf?A%0c;quwbFnIjPIF|tF1f@GO8~JTn#^R=) z12zFi79^CU@y45BzboKJam#@9`#L5p*$k%E`#)$)rc2rBysMjMw!vAwwzFM(v<|_; z*}Tlsp)sr=+?cmAnGS^5_6^7j)}Be?%NjewyU4 z+dhQ01d#@WwLla)7mC2ibUVdM4lSpe;?sn%qC!zB{1Y)7=X(mHSMt;is)LmWEVLUi zM@eGRlz;=N>+Ii^_7g(wH!Rk01e|?d&Dn$jR|e3$DGn zp2V8@=&nG_CE)iUu$b9mwR1KY7m4f8d@@D@%OGvcOsHu9U5B)B-cfJjNb)5P)A8{C z{9MM+eH0qR4N!Ko`zDz6A3wAwsMBJ3z|jBrDe}{L$^B_wz1g@v+HD@u?Y3{tlZf$AWk+r@Yd1|wiliT2ca7VG|E+Oq zHOM8Oy19xE4iM>keJ+B$)q6u!=tQDB*=Xqq4CDaXQ*Oy%gNG>gm_JZO?5zr9c+thv z;~;{3)lp!}vI} z25{=_Uo5w5CPt&9?s7W^h>d$69e=uiornk*+LW_QE>=|-sPP<>Pt@&fzF##S;V*4m zP7q)YPc&XwF=nUkH$y~4z8tO)49nciVxRD7-`NK$n?=Gp6x?EYZp%-~NSk|b7ti>- zQ&GgFSgVAvrr=tOs5i5j)BPITz|K_LV>7g#9;VGP&ge}qKLuPlyT%+oi|A~9m8KZ) zVK1*ji5|mX;bCTL+p{gPud-ez;17o%VJ~@eQ+-`I22M#e;_Ae~Ap+uTuwxO)Fb!{N zkU++Bg1NWAsNc2Nav!Z>tW=gT9-D#{kOlN ze1%FYA}l5K7Qd1Y7;WwM7Qvm9=+jV+LA1Q0=vubUWLP0<`CTZz4E|t_om8X)i&xf!n-w?!AkMxw0iRDE zT`&~YZkrkm$5jN8!80BlRgUs5?^hQJH zJWZC}oggHlKrBbN8|6k_%G(#T?4ED+R|IULp= z0Q~@DXD7@HpUqt&3yz6--)-wI#}B8MqlV>j@x=nxG+y^lLxWUDUOfSN2pV_sOkHQT z>?2xAC?3_Ua=6qfgxDl`jl6eX^JtTi9~+V`R1{;Ss-EA2_r9Nrcg_VG_Pt3W;l~n} z$)l$khzdzhYE3bLCV+9I`qcrbh;U`u!8NTDB*)Nf+hfce4&<%*MmV@qby;v`vjArO z%2<~buiSV8BKto9yG<%};_z6+M3h`2#JdnQ29X4P99rT^J!?jc35(hpSr3T`?8D2qVZ&&7V z0#1*JI|sscCdGVp{2$rAY(bFVO-g`*1}Ch38Xf@8fAAPwe7l zo^v1K`os-4M2$*`f`pdk?jM!Ow!;WS0#cHQP@3A1LTVi_%XR=Zq_mW19Ao;T26W?L z5}3!ILKqS9BV?!|(y{HGfbB}D%2bv6!;%>?aADtJgWr&3pUVaHk0O(|0wyV{^&Fn5 z`&;zH_dp$P>se#Oj-5?VPrM9bG6uRo>zRK>7`U^CJgwxo(Np~lPe^QA^uGPmeoDKv zU{i@V%-rzX=9{zBY}fnrh@AB1tq?;F_oMxW?xar%oM58<tjE!Bi5 zfoz5(zXjCiFZg_TmGI@F(x z104(^!m-|4%r15n)FdG%?Mx~|r16@1*`V6e5g5_***ZUXKl#&IZ4HO(VYHhQwC}79 zh>3WjCH<=vKyGpswRWoW$?1$cKJF~wFI{fR-QP45SseB*6;V>(PK*-_?H8bPM8zho zHNKTvT`Zt^EqoKuRUc6JxG~h&mVWA|)*5r{^adJRe-Ax|0Zlv`NKWf-!?eE*AHM(X9F=XorT^9S^=sTI z&WqDry$YmL(HJ=x7{YJ0wYNwc5|*Lv!KyH%(B7INiw7!&$%4mO;s+GZqBnUboSQ|%BTU#wK^H%#^lf2MRtMFKoN-FCBb5XUSYg`97UHmsT0%3xvRi~8|@ ztTMv64}gF~1<&79-1Ie&oh}V^s))lPC4%AL zV`)?|cDg9M(OESIM1OTIl0SZtSK%KK0*b}N3(^b{1B5xoEQdn`hC$_0cb%L2$Bgy? zzYgtp4nQx^BLfx;XnizqJSMhozQ9ES^5K6-=EQF8Q83 zW$d6>5?JjRD|e>Onb-clT{pdwv>h8ChD&k+q!x}P}T^i-P)JR`#)!jA}TTuO-JYZ^A6o{SgpW^pv-?s?b)_yO#^JxPe`COUm%kk^0tqy0_ zlm{&^6h^SiNrK3fli9nkc!W(v!gw}{-IQ3Jda*g13M}%lr}ED3P~wyIWg(Ockjr4e z39sYVhlXtK9o5&5#_Q;Wm>+#ZJ z)ozindjG7ac=8F0o4YZ3pA`6PyXVT)abLMSiWb!SDs%`?LDXucT&3cah{M)Ak42zD zBbK40ZH8jCO6q5W!1ggZMEG2rES30la4av}dOtk4JkzkX_HZ3Ns{8@5Ka&QA{qXcT zfxhy;${SU>t{_aM{CDfk#Y(~m5A8P?DR_Nzk26dOdorF&*!GSD>4pg-oL?AyL*^5c zc{6J{nS%C3us2uNZ&)59tjluTp;?1P(#(Uk+H_n-#qNqZb87j3vy zJd9@ygIt#}%7vS0^9NGT<}5zBWcfmIYDTY=rHtRHVB0r$n5+xUx)7qS_u0Yw8YRB5 zXH9qg^`Sjei^>fsZGYGt-C8amsQMpt+EpIh!z)iJOl-xDiayY4oWR|M0Z6diK~8D$ zJEj(RRn@#DSGI{VN>|@iK_ZwPHMrX&%v`np0gb2J+H^WR^GHXGs5EEs(^auXjtNuO z3;Z<7Kc**I<^RL6> zJAZ()RxjUJa(Hjnr)tRmdi#0;$~Zh1`MT_U(!v$m79C(g?EnCng9P2O(}+2!!Tl&w zIsi;Uow9IA>YLRbb&>`BsyU~=nHaE>@WoL7VRb6T;gF{$x&Ag2sJsgB>2Ul>vBB$@|%virxi4s{A5 z?3(x;-M>Fymqj5D)H)=RUXKX&>lI}d#S_%l`<2|2jo_kwL^w(G+C~_*&Xik>ztNZ! z3M`8kaW381Tt`Oz_yrw4l36qA&E972@kO1smryYpPA&wNmZTK1`}OKtC?MCZ`Ii9TR*feVV4u$u7Y@uP)fY)v$^g zT2{^~Eg@SL{^eNuAM#9Y@jlD!lbn{_N?NYz>gRFGwSEb|d)YD@#XkYN$-C_vtIn)8 zaJ(l;@Wfa}IOr{$3X~8UT^Ls?@(`SiM3QP~5T@+f6*9DafynAZQ^+gRhE`hUrN1;2 z1*%wo>&z(EUg&@P+$HQ*3cXMD`H@d{{p0RY37A7Yu^;$jCaA5X|xkq6UM6BA@&a|~AU?X(H&z^>{BL?4}4PPEa z2w#0IgPP)Q-lSJsWUskHapg!kDNCr_hQpC)c_GS;ytMXoNeRPOgP7wVrL5 z{nmQB83FmLhPe$llT;~>h=-$!3w(VFzg%ybR*HV143;}Xm31P(kvp{zj_T}I5a28Rs|I(`@ApPcy+ldH+rL+tYl+4`7 zR#Z&dDx+U8O(GO>lj*d zaZ}ak^0lmT+d=%y^0CL(Q&#{*}{#s&MH)hEDBH!^JEDA z^|LZ^K-){ON=;Otop_qqLjBLSRAIr*dLrLbRH<-{6O2&l^M261z$4@1^ZQ3}DxGQo z5G7axXMrxPtF9Ua7LMPj|7F}>N2w2CGolQgb2!!_?s<2q&?F5VDLnlmxuke($Q4*BQubSau zLmau(p-$ER5ckV3$<3y=@8!eJL(c~rY1uhnDZXdj!uV=LTRqmzP1ZL}+H8`gktb=! zqlEJKgL*jtPDHePZgXqEUiL|zT$3;?_3_9z_N4!QuwL!c24#xGh?aXLsYHm;;Gd=K zZGTxuEv;`07jAvNfGTp>Xe^_0}!!W zm+q=-F1YTa^-z(Uske%gW6zM`6S|a~6JLEA>-*ZIXjtYQhxs0O_A8ShwJRLl4Yf6TSCFUjN9MiElgKmM(o*%oS+pMGI_WN_da=Y=++`en>r z`ET|LkPXyJ=L_^mzQYmvkDn_50+l&LOHHOg=451nS+3(-!n|@0#^XKWv{LYMG ziJe^vvEZG>6R}fMJ)w^u@AeHS)LeZZy8jJJ+mzS;OzC}51OO}{uY6QII5J#Zo*0ol z!Sg3c=`e`WFe)q8obau|VLw%LIvRd$_zXU=pav}kSL35P?v>JtGQkZP&%B;S&V>yf zGevFU4{BI=!lZ0I%aoNjNf=!bJchbkza-{}9Gux&x8CIkFsNtA8XK|kf9;)TQxjUd zhC_z{A%qyZhENPG^eRXTNS7`NB@&uQq{#+BrNmGINDn2l6-?+I1OZX05<#R22v`t; z6cy=PEWGUV;e0ti;XThkSTompX3gC9tP7D=uFiAxW6mzSISOTeQM$UY*FvA7B5wcp z;B3e}3~8UjLC?Q6!3k!soQBpTLlYt{Zp4QfG10>SXf)= z8-4=-wAh41Nl-Q(?>fm`HH8S4NAu#CAujezwHG-Y31YPFJk-y3h6a5B*v@-75*2>% z0Qc={)~x2x0TN8!Nw!9JJ}2W>`hGWk?Ze%~ZYeVXCb+X^Fye8!t4g^2y8hV62csQm z$Z_$cS~;!ZF4rhxT8jOJU50_o3l1)!Yzu2+Jba|6XCi&NX@Bw5xX;oQ(msNRe+&Z7 zz04=YcXcfQyKF#?Jk>*$O^W_7Xk#Z7)=Ol&W6G^0q6Y_wisnLnx%FY>oY^>zz!?&Y z);fR3a*4Mk#fm>>F(0JbEmx+ zwkCGw8YneYRoDMB0NaJg!Q{dI3t4=TwdwZG{aq}qMxKx(i_zp4Wd}%-5BK1?CnI%7KZf1gx7c1vrLS%}4 zppd(Fw-3n>idq@Zl^TF6s0a?Gx}P>Oa!9f(qV$go$se@rM1PVLzyCC*L?gYmA?maE zt9p;Dr#}Nw%?6>hxaH`^9qexJ8@&QuwNchRdtCs4sMj6(_GyTwB7L?z#6HdFWgkbjFddxRc=ATGx*xK$FWf- zHmjm5+AMKDS~8CmD}UcJI&O9Wr*g#su-0_!aMr`vnM6V#jQG5A$XWS)p6HvFJRR4c zJaGyKgNz?#8>dwIbptU5kPF*+Q$}~Y;^dwlUOOy!u^!Q;<)3YQ_|w}{5q|z{(sZO} z_eY0clZyMVbXzJHeh#sB;xr@lIo&lZKKe5z3nT9C;l!wxJL#)!-Y1zp0AvQR8_Ai* z*$~#Xgr-1eyYv07r(j$)4QW>o$R9KJ>Z={McHI6!HSm&$;_(0S^Nx?1YoebXbIZYf zMrb8UTaC8}OI;l}}nEesoE0TJ$ zQjyc_+h&VAM`?P<`~w{O)Ta#!Tlx2uDCBT-l7{SMf5P3i!S?nyvI_-&Z>ggRGOc%0 zo)MT%dqpT;M?}~D8G+w>b?5n>UWt~WT8$uNF4(8|x+2WAtAjpoQ^)c%=r&wxZ+x zhO6R;lf9Uc^ZSbWH=vWuMDm5WWmR?xr-%c$RYL<1kAWlt=Zn@vo|G2JzY}Crac*g< z%bsQlQg~JZ&tSn6+vIg{@5MSlMs!sB%qFpq3|Fx<~vq zdCgO1m9k>-BaC}pi628&Hx{^};ztqpuP~F8XL*+>QC0Vs|EE88oWV@R8HiuB&kZOS zrui^KV3^2_Ylg98EaY^?F0V1at*mS#Q$ZzsrsksCXj)95^MsYm?_$M;~#TZBKnjfTT&Ov4^Tkd(tzu9==|5FsZn(mqTxY(SzT}{#* z3{?cKw`+DGb2iIP;_K!R?brRUcJ+4Ewl!gDaz~j zGO^R^8P!le)jID!^>$jXShjxSE20S&PZr*~fVw93107r%^|$*`Ux2@j-1ep7 zxl9$`O0?um7QC;g3)z|L&jPpjCD^U7=WXn74l* z{-pmS!u@Mrc|E*0L84?Zh1~Hq(%l(C_eD3q&N7%1EjpaVSmhF-L=rA>p2wW=PNvOG z%_8$LzZb`1a2tG0dr+QSRa}s)s1r@!ifa6$;fy=pI%d?I^d!PYabQs$zz_`}O%$(a z0>}qp;MWoB8c16Bb@p?mS}WnHAkeAKA~eNKl>^@xj7sNY4?;`kdRP}KtP=LBKKga| z00qss996Hw69tyAtxZa{gE59$r~lGaD)}<@+z)`RPjL^{E0tvE%=A6_1q;+w!Db$9 zdc&%#Kvw-Dp^e*hqry1hf3R~&p4+&M&jm^IhdD0ahv?kq18*Oc=Co)1C84niU~T=o z&Wi?yeJXKf^J{NF1=R=GiMRcpEo5<;jHwacM;}-@K6=`dVw1|$Qr^5Wgg}fSU$4YH zSU-*r+#lW@X<6PH4=bpUxKChI5Y_!`FF<PUD zk68E{Rr#kRwDZI7e4a2jTBcZ)<<)jbSK!3+ijTGOv}Lz{`Pl?99rPZHV{S&Emxm$@ z`4p&JJ@G&zHE_;cHvqsAR znn!Ps?f*(^Hl5`kwTqoF9aTCOWb>}fWrq1_)rsARwDHJNMaMYxOw9lj0D}ik{U$6b zXD!5cbV=W(U z>2cJs@F-HJ_DyGu*~Nuummwx たりない観察 - - - - - - - - + + + + + + + +
@@ -159,19 +159,17 @@
-
- +
+
表示カテゴリ
- - - - - - - - - + + + + + +
+
@@ -293,173 +291,173 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js index c98a1e8..6dcbda4 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -33,6 +33,7 @@ const STATISTICIAN_TARGET = 30; const LIVELY_MAKING_TARGET = 100; const MEMENTO_MORI_TARGET = 10000; + const MAD_SCIENTIST_TARGET = 10; const FULL_SEASON_CYCLE_DAYS = 20; const PRE_UNLOCK_DESCRIPTION_IDS = new Set([ "natural_zunchi_slave", @@ -64,6 +65,7 @@ "elite_few", "lively_making", "memento_mori", + "mad_scientist", ]); const ACHIEVEMENT_CATEGORIES = Object.freeze([ Object.freeze({ @@ -81,7 +83,7 @@ }), Object.freeze({ id: "population", - title: "\u7e41\u6b96\u30fb\u4eba\u53e3", + title: "\u4eba\u53e3", ids: Object.freeze([ "first_birth", "natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations", "birth_50_in_60_seconds", "great_mother_1000_births", "eternal_history_generation_10", @@ -97,7 +99,7 @@ "fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation", "ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge", "mercury_lifespan", "revolution", "fuel_to_fire", - "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", + "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", "mad_scientist", ]), }), Object.freeze({ @@ -202,7 +204,8 @@ Object.freeze({ id: "statistician", title: "\u7d71\u8a08\u5b66\u8005", description: "\u7d71\u8a08\u306e\u500b\u4f53\u6570\u30fb\u6b32\u6c42\u30fb\u74b0\u5883\u30fb\u30b0\u30e9\u30d5\u8868\u793a\u5207\u66ff\u30dc\u30bf\u30f3\u3092\u7d2f\u8a0830\u56de\u62bc\u3059\u3002" }), Object.freeze({ id: "well_informed", title: "\u60c5\u5831\u901a", description: "\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3059\u308b\u3002" }), Object.freeze({ id: "lively_making", title: "\u8cd1\u308f\u3044\u3065\u304f\u308a", description: "\u300c\u305f\u308a\u306a\u3044\u3092\u8ffd\u52a0\u300d\u3067100\u4f53\u8ffd\u52a0\u3059\u308b\u3002" }), - Object.freeze({ id: "memento_mori", title: "\u30e1\u30e1\u30f3\u30c8\u30fb\u30e2\u30ea", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }), + Object.freeze({ id: "memento_mori", title: "\u591a\u6570\u306e\u6b7b\u306f\u7d71\u8a08\u4e0a\u306e\u6570\u5b57\u3067\u3057\u304b\u306a\u3044", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }), + Object.freeze({ id: "mad_scientist", title: "\u30de\u30c3\u30c9\u30b5\u30a4\u30a8\u30f3\u30c6\u30a3\u30b9\u30c8", description: "\u7570\u306a\u308b10\u5339\u306e\u305f\u308a\u306a\u3044\u306b\u602a\u3057\u3044\u85ac\u3092\u6295\u4e0e\u3059\u308b\u3002" }), ]); const definitionById = new Map(DEFINITIONS.map(def => [def.id, def])); const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [ @@ -246,7 +249,7 @@ let scheduledProgressSaveTimer = 0; function blankState() { - return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0 } }; + return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } }; } function loadState() { @@ -299,6 +302,7 @@ statsButtonPressCount: Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(progressSource.statsButtonPressCount || 0) || 0))), totalDeathCount: Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(progressSource.totalDeathCount || 0) || 0))), manualTarinaiAddedCount: Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(progressSource.manualTarinaiAddedCount || 0) || 0))), + mysteryDrugTarinaiIds: [...new Set((Array.isArray(progressSource.mysteryDrugTarinaiIds) ? progressSource.mysteryDrugTarinaiIds : []).map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET), }; const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { @@ -449,6 +453,10 @@ const count = state.unlocked[id] ? MEMENTO_MORI_TARGET : Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress?.totalDeathCount || 0) || 0))); return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / MEMENTO_MORI_TARGET * 100).toFixed(1)}%\uff08${count} / ${MEMENTO_MORI_TARGET}\u4f53\uff09`; } + if (id === "mad_scientist") { + const count = state.unlocked[id] ? MAD_SCIENTIST_TARGET : Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0); + return `\u73fe\u5728 ${count} / ${MAD_SCIENTIST_TARGET}\u5339`; + } if (id === "continuous_play_1_hour") { const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs()); return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`; @@ -487,6 +495,7 @@ Math.min(STATISTICIAN_TARGET, Math.max(0, Number(state.progress?.statsButtonPressCount || 0) || 0)), Math.min(MEMENTO_MORI_TARGET, Math.max(0, Number(state.progress?.totalDeathCount || 0) || 0)), Math.min(LIVELY_MAKING_TARGET, Math.max(0, Number(state.progress?.manualTarinaiAddedCount || 0) || 0)), + Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0), Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10), ].join(":"); return `${unlocks}|${rates}|${local}`; @@ -656,6 +665,7 @@ function runNextUnlockToast() { if (toastActive || !toastQueue.length) return; const definition = toastQueue.shift(); + global.TarinaiAudio?.achievementUnlock?.(); if (!dom.toast || !dom.toastTitle) { global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title} \u2014 ${definition.description || ""}`); global.setTimeout(runNextUnlockToast, 80); @@ -919,6 +929,8 @@ state.progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress.statsButtonPressCount || 0) || 0))); state.progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress.totalDeathCount || 0) || 0))); state.progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress.manualTarinaiAddedCount || 0) || 0))); + if (!Array.isArray(state.progress.mysteryDrugTarinaiIds)) state.progress.mysteryDrugTarinaiIds = []; + state.progress.mysteryDrugTarinaiIds = [...new Set(state.progress.mysteryDrugTarinaiIds.map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET); return state.progress; } @@ -990,6 +1002,7 @@ state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount), state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount), state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount), + state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length), ], ]; } @@ -1083,6 +1096,10 @@ progress.statsButtonPressCount = Math.max(progress.statsButtonPressCount, Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(packed[14] || 0) || 0)))); progress.totalDeathCount = Math.max(progress.totalDeathCount, Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(packed[15] || 0) || 0)))); progress.manualTarinaiAddedCount = Math.max(progress.manualTarinaiAddedCount, Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(packed[16] || 0) || 0)))); + const importedMysteryDrugCount = Math.min(MAD_SCIENTIST_TARGET, Math.max(0, Math.floor(Number(packed[17] || 0) || 0))); + while (progress.mysteryDrugTarinaiIds.length < importedMysteryDrugCount) { + progress.mysteryDrugTarinaiIds.push(`imported:${progress.mysteryDrugTarinaiIds.length + 1}`); + } const progressChanged = previousProgress !== JSON.stringify(progress); changed = changed || progressChanged; @@ -1570,6 +1587,19 @@ tarinai._achievementFightMochiSerial = serial; tarinai._achievementFightMochiWorld = world; } + if (type === "mystery_drug" && tarinai && !state.unlocked.mad_scientist) { + const progress = ensureProgress(); + const identity = String(tarinai.familyKey || tarinai.id || ""); + if (identity && !progress.mysteryDrugTarinaiIds.includes(identity)) { + progress.mysteryDrugTarinaiIds = [...progress.mysteryDrugTarinaiIds, identity].slice(0, MAD_SCIENTIST_TARGET); + if (progress.mysteryDrugTarinaiIds.length >= MAD_SCIENTIST_TARGET) { + changed = unlock("mad_scientist", { ...detail, world, tarinai, count: progress.mysteryDrugTarinaiIds.length }) || changed; + } else { + saveState(); + renderIfDialogOpen(); + } + } + } if (MEDICINE_LEDGER_TYPES.includes(type) && !state.unlocked.medicine_ledger_all) { const progress = ensureProgress(); if (!progress.medicineTypes.includes(type)) { @@ -1857,6 +1887,9 @@ } function recordManualTarinaiAdded(detail = {}) { + // Adding a Tarinai is a direct player intervention and must invalidate + // the no-operation window used by the surveillance-camera achievement. + recordIntervention({ ...detail, tool: detail.tool || "new-tarinai" }); if (state.unlocked.lively_making) return false; const progress = ensureProgress(); progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount + 1); diff --git a/js/ants.js b/js/ants.js index f615baf..9fcd59d 100644 --- a/js/ants.js +++ b/js/ants.js @@ -100,7 +100,7 @@ class AntActor { this.targetRef = null; this.burnWanderAngle = rand(0, Math.PI * 2); this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.0); - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" }); return true; } @@ -108,7 +108,7 @@ class AntActor { if (!this.burning && !(this.burnTimer > 0)) return false; this.burning = false; this.burnTimer = 0; - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(9, this.r * 2.2), life: 0.16, color: "rgba(108,192,236,0.58)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(9, this.r * 2.2), life: 0.16, color: "rgba(108,192,236,0.58)" }); return true; } @@ -158,7 +158,7 @@ class AntActor { if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt); } if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) { - this.world?.effects?.push(new Effect("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" })); + this.world?.queueEffect?.("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" }); } if (this.burnTimer <= 0.01) this.extinguishFire("timeout"); return true; @@ -300,6 +300,7 @@ class AntActor { updateSearch(dt, home) { const target = this.chooseTarget(52); if (target) { + const alreadyCaptured = Boolean((target.antDraggedTimer || 0) > 0 && target.antDraggedBy); this.state = "drag"; this.targetId = target.id; this.targetToken = target.liveToken || 0; @@ -310,6 +311,7 @@ class AntActor { target.fearTimer = Math.max(target.fearTimer || 0, 0.55); target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"; target.recordChangeCause?.("\u30a2\u30ea\u306b\u904b\u3070\u308c\u305f", "\u72b6\u614b"); + if (!alreadyCaptured) this.world?.log?.(`${target.name}\u304c\u30a2\u30ea\u306b\u6355\u307e\u3063\u305f\u3002`, "event", { participants: [target] }); audio.antDrag?.(); this.world.drawListDirty = true; return; @@ -416,10 +418,6 @@ class AntActor { globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, target, { world: this.world, reason: "tarinai_struggle" }); this.hp -= dt * struggle; - if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { - this.carryLogAt = this.world.time || 0; - this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event", { participants: [target] }); - } if (this.hp <= 0) this.dieAsCorpse(home); } @@ -431,7 +429,7 @@ class AntActor { const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; this.world.addItem?.(corpse, "ant-corpse"); - this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" })); + this.world.queueEffect?.("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }); if (home) syncAntNestCount(this.world, home); this.world.ensureItemBuckets?.("ant-corpse"); this.world.drawListDirty = true; diff --git a/js/audio.js b/js/audio.js index 1620371..99402d6 100644 --- a/js/audio.js +++ b/js/audio.js @@ -297,6 +297,7 @@ const audio = { explode() { if (!this.playSample("sfx_firecracker_explode", 0.30, "ops")) this.play("sfx_firecracker_explode", { category: "ops", minGap: 0.30 }); }, phase() { this.play("sfx_phase", { category: "notify", minGap: 1.0 }); }, notify() { this.play("sfx_notify", { category: "notify", minGap: 0.12 }); }, + achievementUnlock() { this.ensure(); this.play("sfx_achievement_unlock", { category: "notify", minGap: 0.35 }); }, uiClick() { this.play("sfx_ui_click", { category: "ops", minGap: 0.035 }); }, uiFold() { this.play("sfx_ui_fold", { category: "ops", minGap: 0.08 }); }, place(type = "") { diff --git a/js/circuit_board_system.js b/js/circuit_board_system.js index 38cdc4e..6f5a470 100644 --- a/js/circuit_board_system.js +++ b/js/circuit_board_system.js @@ -1099,7 +1099,7 @@ dialog.querySelector("[data-action=cancel]").onclick = 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"); worldRef.log?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u7d44\u307f\u66ff\u3048\u305f\u3002", "observe"); } + if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("circuit-board-edited"); } global.showToast?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u53cd\u6620\u3057\u307e\u3057\u305f\u3002"); global.render?.(); close(); }; diff --git a/js/colony_situation_system.js b/js/colony_situation_system.js index 038aefd..545f6d5 100644 --- a/js/colony_situation_system.js +++ b/js/colony_situation_system.js @@ -55,11 +55,18 @@ const crisisCollapseSignal = m.weakRatio >= 0.70 && m.criticalRatio >= 0.60; add("devastation", 120, m.n <= 10 && devastationSignal); add("crisis", 110, !m.populationIncreasing && ((crisisMortalitySignal && crisisSystemicStress) || crisisCollapseSignal)); - add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25); + const confusionPanicThreshold = Math.max(4, Math.ceil(m.n * 0.32)); + const confusionShockPanicThreshold = Math.max(3, Math.ceil(m.n * 0.18)); + const confusionShockSafetyThreshold = Math.max(5, Math.ceil(m.n * 0.30)); + const confusionSignal = m.panicCount >= confusionPanicThreshold + || (m.recentShock && m.panicCount >= confusionShockPanicThreshold && m.safetyHighCount >= confusionShockSafetyThreshold); + const breedingThreshold = Math.max(4, Math.ceil(m.n * 0.18)); + const breedingSignal = m.breedingCount >= breedingThreshold && m.breedingRatio >= 0.18; + add("confusion", 100, confusionSignal); add("fearful", 90, (m.avgSafety >= 50 && m.safetyRatio >= 0.28) || (m.recentShock && m.safetyRatio >= 0.18)); add("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16)); add("weakened", 74, !m.populationIncreasing && ((m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34)); - add("breeding", 66, m.breedingRatio >= 0.10 || m.breedingCount >= 2 || m.now - (worldRef?.lastBirthAt || -999) <= 45); + add("breeding", 66, breedingSignal); add("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18); add("isolated", 28, m.n <= 1); if (!candidates.length) return "relaxed"; diff --git a/js/data.js b/js/data.js index d270a43..e32a970 100644 --- a/js/data.js +++ b/js/data.js @@ -51,7 +51,7 @@ const CONFIG = { hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, energyPerMinute: 8.2, - reproductionCooldown: 18, + reproductionCooldown: 30, dayLength: 120, standardTemperature: 15, summerTemperatureBoost: 0, diff --git a/js/fire_runtime_system.js b/js/fire_runtime_system.js index f6f0daf..a286aea 100644 --- a/js/fire_runtime_system.js +++ b/js/fire_runtime_system.js @@ -81,7 +81,7 @@ function updateFirecracker(item, dt, worldRef) { function consumeWaterForFire(worldRef, water, amount = 8) { if (!water) return false; water.amount = Math.max(0, Number(water.amount || 0) - amount); - worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" })); + worldRef?.queueEffect?.("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }); worldRef?.markItemBucketsDirty?.("fire-water"); worldRef?.markSpatialDirty?.("fire-water"); return true; @@ -160,7 +160,7 @@ function updateFirecracker(item, dt, worldRef) { box.flameWaterCooldown = 0; box.nestFireEffectCooldown = 0; worldRef?.log?.("\u5de3\u7bb1\u306b\u706b\u304c\u71c3\u3048\u79fb\u3063\u305f\u3002", "danger"); - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" }); } worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire"); worldRef?.markItemBucketsDirty?.("nest-box-fire"); @@ -179,7 +179,7 @@ function updateFirecracker(item, dt, worldRef) { box.nestFireWetUntil = reason === "water" ? ((Number(worldRef?.time || box.world?.time || 0) || 0) + NEST_BOX_FIRE_WET_REIGNITE_DELAY) : 0; box.flameWaterCooldown = 0; box.nestFireEffectCooldown = 0; - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" }); worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire-extinguished"); worldRef?.markItemBucketsDirty?.("nest-box-fire-extinguished"); worldRef?.markSpatialDirty?.("nest-box-fire-extinguished"); @@ -207,7 +207,7 @@ function updateFirecracker(item, dt, worldRef) { } if (count) { worldRef.log?.(`\u708e\u4e0a\u3057\u305f\u5de3\u7bb1\u304b\u3089${count}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u8ffd\u3044\u51fa\u3055\u308c\u305f\u3002`, "danger", { participants: occupants }); - worldRef.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" })); + worldRef.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" }); worldRef.markSpatialDirty?.(reason); worldRef.drawListDirty = true; } @@ -231,7 +231,7 @@ function updateFirecracker(item, dt, worldRef) { box.dead = true; } worldRef?.log?.("\u5de3\u7bb1\u304c\u713c\u5931\u3057\u305f\u3002", "danger"); - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" }); worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-burnout"); worldRef?.markItemBucketsDirty?.("nest-box-burnout"); worldRef?.markSpatialDirty?.("nest-box-burnout"); @@ -290,7 +290,7 @@ function updateFirecracker(item, dt, worldRef) { fire.flameLife = Math.max(0, Number(fire.flameLife || 0) - 0.85); grass.health = clamp((grass.health ?? 1) - 0.035, 0, 1); if ((fire.amount || 0) <= 24 || (fire.flameLife || 0) <= 0.35) fire.amount = 0; - worldRef?.effects?.push(new Effect("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" })); + worldRef?.queueEffect?.("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" }); worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 28), "grass-extinguish-fire"); worldRef?.markItemBucketsDirty?.("grass-extinguish-fire"); worldRef?.markSpatialDirty?.("grass-extinguish-fire"); @@ -590,7 +590,7 @@ function updateFirecracker(item, dt, worldRef) { } pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item); } - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" }); item.amount = 0; worldRef.markItemBucketsDirty?.("flame-firecracker"); worldRef.markSpatialDirty?.("flame-firecracker"); diff --git a/js/health.js b/js/health.js index e9868e9..1218c08 100644 --- a/js/health.js +++ b/js/health.js @@ -196,14 +196,14 @@ const HEALTH = (() => { if (typeof Effect === "undefined") return false; if ((world.effectCounts?.bubble || 0) >= (CONFIG?.bubbleLimit || 80)) return false; const r = Math.max(16, t.radius || 22); - world.effects?.push(new Effect("bubble", t.x, t.y - r * 1.38, { + world.queueEffect?.("bubble", t.x, t.y - r * 1.38, { vx: (Math.random() - 0.5) * 2.4, vy: -4.8 - Math.random() * 2.8, life: 2.4 + Math.random() * 0.6, size: 9, text, color, - })); + }); world.effectCounts.bubble = (world.effectCounts.bubble || 0) + 1; t.nextBubbleAt = Math.max(t.nextBubbleAt || 0, (world.time || 0) + 1.55); t._damageBubblePriorityUntil = Math.max(Number(t._damageBubblePriorityUntil || 0), (world.time || 0) + 1.55); diff --git a/js/history_system.js b/js/history_system.js index 6620c0d..626a3fd 100644 --- a/js/history_system.js +++ b/js/history_system.js @@ -2,8 +2,8 @@ // User-facing undo/redo for direct editing actions. (function (global) { - const MAX_HISTORY = 48; - const MAX_HISTORY_BYTES = 16 * 1024 * 1024; + const MAX_HISTORY = 32; + const MAX_HISTORY_BYTES = 8 * 1024 * 1024; function ensure(worldRef) { if (!worldRef) return null; diff --git a/js/impact_response_system.js b/js/impact_response_system.js index 5ac7c0a..0d81576 100644 --- a/js/impact_response_system.js +++ b/js/impact_response_system.js @@ -78,7 +78,7 @@ ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (10 + p * 12), -48, 48); ball.lastPokedAt = worldRef.time || 0; ballHit += 1; - worldRef.effects?.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" })); + worldRef.queueEffect?.("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" }); } if (ballHit > 0) worldRef.markSpatialDirty?.("genkotsu-ball-impact"); } diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index 2b71cde..b513735 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -138,7 +138,7 @@ item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); if ((item.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(item.vx || 0, item.vy || 0) > 120) { item.lastHaySlowLogAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }); } } @@ -217,7 +217,7 @@ if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return; item.lastObstacleBounceAt = now; const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)")); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }); } function normalizeAngleLocal(angle = 0, fallback = 0) { @@ -342,7 +342,7 @@ const phase = Math.sin(item.fanPulse || 0); const px = item.x + ax * (42 + 24 * phase); const py = item.y + ay * (42 + 24 * phase); - worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" })); + worldRef?.queueEffect?.("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" }); } return true; } @@ -372,13 +372,13 @@ // A fan should carry embers forward instead of merely dimming the source flame. // The source loses a little fuel, while the downwind flame handles normal fire contacts. fire.amount = Math.max(4, (fire.amount || 0) - (2.2 + falloff * 3.8)); - worldRef.effects?.push(new Effect("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), { + worldRef.queueEffect?.("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), { size: Math.max(7, (fire.r || 14) * 0.72), life: 0.22, color: "rgba(255,126,54,0.58)", vx: ax * (80 + falloff * 120), vy: ay * (80 + falloff * 120), - })); + }); worldRef.markItemBucketsDirty?.("fan-fire-spread"); worldRef.markSpatialDirty?.("fan-fire-spread"); worldRef.drawListDirty = true; @@ -429,7 +429,7 @@ t.lastFanSurpriseAt = worldRef?.time || 0; t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12); if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 }); - worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" })); + worldRef?.queueEffect?.("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" }); } } @@ -522,13 +522,13 @@ if (!addedDrop) continue; spawned += 1; if (i < 8) { - worldRef.effects?.push(new Effect("fight", drop.x, drop.y, { + worldRef.queueEffect?.("fight", drop.x, drop.y, { size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i), life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i), color: "rgba(96,196,244,0.66)", vx: drop.vx * 0.42, vy: drop.vy * 0.42, - })); + }); } } if (spawned) { @@ -566,7 +566,7 @@ } if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) { item.lastSplashTrailAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 })); + worldRef.queueEffect?.("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 }); } worldRef?.markSpatialDirty?.("water-drop-motion"); worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion"); @@ -624,11 +624,11 @@ // through amount/hp instead of assigning to .dead directly. balloon.amount = 0; balloon.hp = 0; - worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" })); + worldRef?.queueEffect?.("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" }); for (let i = 0; i < 9; i++) { const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i); const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i); - worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s })); + worldRef?.queueEffect?.("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }); } spawnWaterFromBalloon(balloon, worldRef); const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "\u706b" diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js index c4612d8..bb62c03 100644 --- a/js/item_dynamic_pin_system.js +++ b/js/item_dynamic_pin_system.js @@ -63,7 +63,7 @@ if (!ball.stuckPinIds.includes(item.id)) ball.stuckPinIds.push(item.id); ball.stuckPinId = ball.stuckPinIds[0] || item.id; updateBallLodged(item, 0, worldRef); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" }); return true; } @@ -212,7 +212,7 @@ if (isOshibyo) { t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + worldRef.queueEffect?.("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }); return true; } if (t.enterPanic) { @@ -229,7 +229,7 @@ item.pinLogAt = worldRef.time || 0; worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); } - worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); + worldRef.queueEffect?.("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }); return true; } @@ -342,7 +342,7 @@ item.vx = clamp(baseVx * 0.45 + nx * 18, -90, 90); item.vy = clamp(baseVy * 0.45 + Math.max(12, ny * 18), -60, 110); item.spinVelocity = clamp((item.spinVelocity || 0) * 0.35 + (Math.sign(nx) || 1) * 0.9, -2.2, 2.2); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }); if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306E\u753B\u92F2\u304C\u629C\u3051\u843D\u3061\u305F\u3002`, "observe", { participants: [t] }); } if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js index aa26681..fab788e 100644 --- a/js/item_dynamic_zunchi_system.js +++ b/js/item_dynamic_zunchi_system.js @@ -108,7 +108,7 @@ if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); } worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); - worldRef.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + worldRef.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }); item.vx *= -0.18; item.vy *= -0.18; break; @@ -139,7 +139,7 @@ item.burning = false; item.burnTimer = 0; item.amount = 0; - worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(13, (item.r || 15) * 1.25), life: 0.42, color: "rgba(64,44,34,0.44)" })); + worldRef?.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: Math.max(13, (item.r || 15) * 1.25), life: 0.42, color: "rgba(64,44,34,0.44)" }); } worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-burning"); worldRef?.markItemBucketsDirty?.("zunchi-burning"); @@ -183,7 +183,7 @@ } if ((item.amount || 0) <= 0.5) { item.amount = 0; - worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(12, (item.r || 15) * 1.15), life: 0.34, color: "rgba(64,44,34,0.34)" })); + worldRef?.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: Math.max(12, (item.r || 15) * 1.15), life: 0.34, color: "rgba(64,44,34,0.34)" }); worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-decay-out"); worldRef?.markItemBucketsDirty?.("zunchi-decay-out"); worldRef?.markSpatialDirty?.("zunchi-decay-out"); diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index 4c4482e..2eba1ed 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -69,7 +69,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); - if (options.log !== false) worldRef?.log?.(`\u8907\u88fd\u6a5f\u306b${duplicator.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, isPinType(loadType) ? "observe" : "food"); + // Direct duplicator loading is a player operation and is not an event-log entry. return true; } @@ -102,7 +102,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) { duplicator.usedCount = (duplicator.usedCount || 0) + 1; duplicator.nextDuplicatedPinAt = now + 0.82; duplicator.duplicatorPinFlash = 0.24; - worldRef.effects?.push(new Effect("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" })); + worldRef.queueEffect?.("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" }); worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("duplicator-pin-lodged"); return true; diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 8d9219e..11d2b77 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -188,7 +188,7 @@ const now = worldRef.time || 0; if ((item.lastBounceFenceEffectAt || -999) + 0.08 <= now) { item.lastBounceFenceEffectAt = now; - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, radius * 1.15), life: 0.16, color: "rgba(82,153,230,0.45)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(12, radius * 1.15), life: 0.16, color: "rgba(82,153,230,0.45)" }); } } } diff --git a/js/render.js b/js/render.js index 172eccb..6ec7897 100644 --- a/js/render.js +++ b/js/render.js @@ -1108,6 +1108,150 @@ function isEntityVisibleInRect(entity, rect, extra = 0) { return isPointVisibleInRect(entity.x, entity.y, renderRadiusForEntity(entity) + extra, rect); } +// Stable field objects are expensive because their vector art and shadows were +// rebuilt every frame even when nothing about the object changed. Cache only +// genuinely static visual types; dynamic/mechanical/consumable items keep the +// normal draw path. This preserves exact depth sorting because each item remains +// an individual render-stack entry and only its pixels are reused. +const STATIC_ITEM_BITMAP_TYPES = new Set([ + // Cache only artwork whose vector reconstruction is expensive enough to + // justify a dedicated backing canvas. Small consumables, zunchi, water and + // corpses are cheaper to draw directly and can exist in large numbers, so + // per-item canvases for them waste substantially more memory than they save. + "stone", "bed", "grass_bed", "nest_box", "pipe", "toilet", "rain_shelter", "ant_nest", "signboard", + "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "one_way_fence", "plushie" +]); + +function canUseStaticItemBitmapCache(it, worldRef) { + if (!it || it.dead || !STATIC_ITEM_BITMAP_TYPES.has(String(it.type || ""))) return false; + if (isCachedTerrainItem(it) || isLinkRenderItem(it)) return false; + if (it.playerHeld || it._heldByPlayer || it.dragging || (it.dropTimer || 0) > 0) return false; + if (it.burning || (it.burnTimer || 0) > 0.02 || (it.nestFireEvacuationTimer || 0) > 0.02) return false; + if (it.isStructure && it.type === "plushie" && (it.carriedById || it.onHead)) return false; + if (Math.hypot(it.vx || 0, it.vy || 0) > 0.02) return false; + if (worldRef?.spatial?.isDynamicItem?.(it)) return false; + return true; +} + +function staticItemBitmapState(it, lighting) { + return { + lightBucket: Math.round((lighting?.light || 0) * 6), + warmthBucket: Math.round((lighting?.warmth || 0) * 3), + r: Number(it.r || 0), + angle: Number(it.angle || 0), + amount: Number(it.amount || 0), + servings: Number(it.foodServingsRemaining ?? -1), + servingsMax: Number(it.foodServingsMax ?? -1), + variant: String(it.zunchiVariant || it.variant || it.plushieVariant || it.plushieKind || ""), + toolSize: String(it.toolSize || ""), + text: String(it.text || it.label || ""), + state: String(it.state || it.stage || ""), + freshness: Number(it.freshness ?? -1), + fertility: Number(it.fertility ?? -1), + flash: Number(it.toiletFlash ?? 0), + color: String(it.color || ""), + active: Boolean(it.active), + }; +} + +function staticItemBitmapStateMatches(cache, state) { + return cache.lightBucket === state.lightBucket + && cache.warmthBucket === state.warmthBucket + && cache.r === state.r + && cache.angle === state.angle + && cache.amount === state.amount + && cache.servings === state.servings + && cache.servingsMax === state.servingsMax + && cache.variant === state.variant + && cache.toolSize === state.toolSize + && cache.text === state.text + && cache.state === state.state + && cache.freshness === state.freshness + && cache.fertility === state.fertility + && cache.flash === state.flash + && cache.color === state.color + && cache.active === state.active; +} + +function assignStaticItemBitmapState(cache, state) { + Object.assign(cache, state); +} + +function releaseStaticItemBitmapCache(it) { + const cache = it?._staticRenderBitmapCache; + if (!cache) return false; + // Resetting canvas dimensions releases the backing pixel buffer in browsers + // before the item itself is eventually collected. + if (cache.canvas) { + cache.canvas.width = 0; + cache.canvas.height = 0; + } + it._staticRenderBitmapCache = null; + return true; +} + +function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) { + if (!canUseStaticItemBitmapCache(it, worldRef)) { + releaseStaticItemBitmapCache(it); + it.draw(ctx, time, lighting); + return false; + } + // Close-up rendering remains vector-based, so cached rasterization never + // softens artwork. The expensive whole-field/zoomed-out path is cached. + if ((worldRef?.viewScale?.() || 1) > 1.35) { + releaseStaticItemBitmapCache(it); + it.draw(ctx, time, lighting); + return false; + } + const state = staticItemBitmapState(it, lighting); + let cache = it._staticRenderBitmapCache; + const radius = Math.ceil(Math.max(42, renderRadiusForEntity(it) + 18)); + const size = radius * 2; + if (!cache) cache = it._staticRenderBitmapCache = { canvas: document.createElement("canvas"), ctx: null, radius: 0 }; + if (!cache.ctx) cache.ctx = cache.canvas.getContext("2d"); + if (!staticItemBitmapStateMatches(cache, state) || cache.radius !== radius || cache.canvas.width !== size || cache.canvas.height !== size) { + cache.radius = radius; + assignStaticItemBitmapState(cache, state); + cache.canvas.width = size; + cache.canvas.height = size; + const c = cache.ctx; + c.clearRect(0, 0, size, size); + c.save(); + c.translate(radius - (it.x || 0), radius - (it.y || 0)); + const pointer = worldRef?.pointer; + const oldInside = pointer?.inside; + if (pointer) pointer.inside = false; + try { + it.draw(c, time, lighting); + } finally { + if (pointer) pointer.inside = oldInside; + c.restore(); + } + } + ctx.drawImage(cache.canvas, (it.x || 0) - radius, (it.y || 0) - radius); + // Pointer hover is not baked into the bitmap. Draw only the hovered outline + // live so cached objects still react immediately to the cursor. + const pointer = worldRef?.pointer; + if (pointer?.inside && distXY(it.x || 0, it.y || 0, pointer.x || 0, pointer.y || 0) < (it.r || 18) * 2.2) { + ctx.save(); + ctx.globalAlpha = 0.32; + ctx.strokeStyle = "rgba(255, 250, 220, 0.82)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(it.x || 0, (it.y || 0) + (it.r || 18) * 0.1, (it.r || 18) * 1.35, (it.r || 18) * 0.92, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + return true; +} + +function drawRenderEntity(ctx, entity, time, lighting, worldRef) { + if (!entity) return false; + if (typeof Item !== "undefined" && entity instanceof Item) return drawStaticItemBitmapCached(ctx, entity, time, lighting, worldRef); + entity.draw(ctx, time, lighting); + return true; +} + function carriedPlushieRenderPose(it, worldRef) { if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return null; const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById); @@ -1849,7 +1993,7 @@ function render() { window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length); const endBackItems = renderPerfBegin("render.draw.backItems"); for (const it of renderStack.backItems) { - it.draw(ctx, world.time, lighting); + drawStaticItemBitmapCached(ctx, it, world.time, lighting, world); } renderPerfEnd(endBackItems); @@ -1897,7 +2041,7 @@ function render() { const endLayered = renderPerfBegin("render.draw.layered"); for (const entry of renderStack.layered) { - entry.entity.draw(ctx, world.time, lighting); + drawRenderEntity(ctx, entry.entity, world.time, lighting, world); drawAttachedPinsForEntity(entry.entity); // A carried plushie shares its owner's depth position instead of being // rendered above every entity as a global attachment layer. @@ -1963,7 +2107,7 @@ function render() { // Light affecting the whole visible scene, including characters and ground. const endLighting = renderPerfBegin("render.lightingWeather"); if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting); - if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time, { alphaScale: effectLevel === "off" ? 0.42 : 1 }); + if (world.weather === "light_rain" && effectLevel !== "off") drawRain(ctx, screenW, screenH, world.time); renderPerfEnd(endLighting); const endUiOverlay = renderPerfBegin("render.uiOverlay"); diff --git a/js/robot_cleaner_system.js b/js/robot_cleaner_system.js index 4e72cc4..8195def 100644 --- a/js/robot_cleaner_system.js +++ b/js/robot_cleaner_system.js @@ -96,12 +96,12 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (!key || !id || !worldRef) return null; if (key === "tarinai") { const t = worldRef.liveTarinaiById?.(id) || (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead); - return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null; + return robotTargetAlive(t) ? t : null; } const byId = typeof worldRef.itemById === "function" ? worldRef.itemById(id) : null; const found = byId || (worldRef.items || []).find(it => it && it.id === id && !it.dead); const matchesKey = found && (found.type === key || (key === "ant_corpse" && (found.type === "trace" || found.type === "splat"))); - return matchesKey && robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null; + return matchesKey && robotTargetAlive(found) ? found : null; } function robotTargetCandidates(item, worldRef) { @@ -116,7 +116,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains for (const t of worldRef.tarinai || []) { if (!t || t.dead) continue; if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - if (robotTargetBlocked(item, t, worldRef)) continue; out.push({ target: t, type: key }); } continue; @@ -126,7 +125,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains : (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key)); for (const it of buckets || []) { if (!it || it.dead || it === item) continue; - if (robotTargetBlocked(item, it, worldRef)) continue; out.push({ target: it, type: key }); } } @@ -145,7 +143,11 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains const target = rec.target; if (!robotTargetAlive(target)) continue; const d = distXY(item.x, item.y, target.x, target.y); - if (d < bestD) { best = target; bestType = rec.type; bestD = d; } + // Do not discard a target merely because a nest box or fence blocks the + // direct segment. The movement step can route around it. Prefer direct + // targets when distances are otherwise similar to avoid needless detours. + const score = d + (robotTargetBlocked(item, target, worldRef) ? 120 : 0); + if (score < bestD) { best = target; bestType = rec.type; bestD = score; } } if (best) { item.robotCleanerTargetId = best.id || ""; @@ -232,16 +234,12 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains item.robotCleanerCleanFlash = 0.42; item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1; global.TarinaiAchievements?.recordRobotClean?.({ world: worldRef, robot: item, target, type }); - worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" })); + worldRef.queueEffect?.("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }); worldRef.markItemBucketsDirty?.("robot-cleaner"); worldRef.markSpatialDirty?.("robot-cleaner"); if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner"); worldRef.updateItemCounts?.(); worldRef.drawListDirty = true; - if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) { - worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe"); - item.robotCleanerLastLogAt = worldRef.time || 0; - } return true; } @@ -259,6 +257,9 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (!type) continue; const capture = robotCleanerCaptureRadius(item, target); if (distXY(item.x, item.y, target.x, target.y) <= capture) { + // Keep walls meaningful: a nearby target is collected only after the + // cleaner has reached a side with an unobstructed contact segment. + if (robotTargetBlocked(item, target, worldRef)) continue; if (cleanRobotTarget(item, target, worldRef, type)) changed = true; } } @@ -271,8 +272,19 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0; let desiredSpeed = speed; if (target && robotTargetAlive(target)) { - desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0)); - desiredSpeed = speed; + let moveTarget = target; + const blocked = robotTargetBlocked(item, target, worldRef); + if (blocked && typeof worldRef.findTarinaiPathWaypoint === "function") { + const waypoint = worldRef.findTarinaiPathWaypoint(item, target, { + padding: Math.max(18, r * 0.72), + state: "robot_cleaner", + preferFullPath: true, + maxChecks: 54, + }); + if (waypoint) moveTarget = waypoint; + else desiredSpeed = speed * 0.35; + } + desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0)); } else if ((item.robotCleanerWanderTimer || 0) <= 0) { desiredAngle = global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2)); item.robotCleanerWanderTimer = global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item); diff --git a/js/signal_system.js b/js/signal_system.js index 750d993..bbbf547 100644 --- a/js/signal_system.js +++ b/js/signal_system.js @@ -64,9 +64,11 @@ function emitEffect(worldRef, type, x, y, options = {}) { if (!worldRef?.effects) return false; + if (typeof worldRef.queueEffect === "function") return Boolean(worldRef.queueEffect(type, x, y, options)); + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return false; const EffectCtor = global.TarinaiEffect || (typeof Effect === "function" ? Effect : null); if (typeof EffectCtor !== "function") return false; - worldRef.effects.push(new EffectCtor(type, x, y, options)); + worldRef.queueEffect?.(type, x, y, options); return true; } diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js index 61dfb10..b1c5151 100644 --- a/js/simulation_creature_system.js +++ b/js/simulation_creature_system.js @@ -96,15 +96,40 @@ return true; } + function updateCursorCare(worldRef, dt) { + const pointer = worldRef?.pointer; + if (!pointer?.inside || !global.TarinaiCursorCareSystem?.updateOne) return 0; + // Cursor care is a direct pointer interaction and must remain responsive even + // when the expensive creature pipeline is cadence-throttled. One spatial + // query per frame is cheaper than testing every creature individually. + const candidates = worldRef.nearbyTarinai?.(pointer.x || 0, pointer.y || 0, 220, true) || []; + let updated = 0; + for (const t of candidates) { + if (!t || t.dead) continue; + if (global.TarinaiCursorCareSystem.updateOne(t, dt)) updated += 1; + } + return updated; + } + function updateCreatures(worldRef, dt) { const h = helpers; const end = global.TarinaiPerf.begin("update.tarinai.individual") || null; const visibleRect = h.updateVisibleWorldRect(worldRef, 160); const nearVisibleRect = h.updateVisibleWorldRect(worldRef, 520); const now = worldRef.time || 0; + updateCursorCare(worldRef, dt); const pressure = prepareMechanicalCrowdPressure(worldRef, visibleRect); const pressureActive = Boolean(pressure?.active); - const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000; + // Prevent a low-FPS feedback loop: at large populations a long frame makes + // nearly every visible creature's full update become due at once, which makes + // the next frame even longer. Under normal frame times this cap is disabled; + // only slow frames spread expensive full updates across subsequent frames. + const creatureCountForBudget = (worldRef.tarinai || []).length; + const antiSpiralVisibleBudget = creatureCountForBudget >= 160 && dt > 0.028 + ? Math.max(36, Math.min(96, Math.floor(2.8 / Math.max(0.028, dt)))) + : 1000000000; + const pressureVisibleBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000; + const visibleFullBudget = Math.min(antiSpiralVisibleBudget, pressureVisibleBudget); const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000; const collectDiagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true; const stats = { @@ -169,6 +194,11 @@ if (creatureIndex >= creatureCount) creatureIndex = 0; const t = creatureList[creatureIndex++]; if (!t || t.dead) continue; + // Reproduction cooldown is simulation time, not AI-think time. Keep it + // advancing even when the expensive creature pipeline is cadence/budget + // throttled; otherwise large colonies can stay reproductively locked for + // many in-game minutes after the early population burst. + t.reproductionTimer = (Number(t.reproductionTimer || 0) || 0) - dt; stats.total += 1; const behaviorId = updatePolicy.behaviorId(t); inc(stats.states, behaviorId); @@ -205,8 +235,17 @@ const jitter = stableUnit(t.id || t.familyKey || Math.random(), `visible-creature-budget:${Math.floor(now * 5)}`); t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.20 + jitter * 0.25)); stats.visibleFullSkips += 1; - stats.skipped += 1; - inc(stats.skippedStates, behaviorId); + // Keep movement visually continuous while deferring only the heavy + // AI/environment portion of the update. + if (cadence.smoothMotion && stats.visibleMotionUsed < stats.visibleMotionBudget + && global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) { + stats.smooth += 1; + stats.visibleMotionUsed += 1; + inc(stats.smoothStates, behaviorId); + } else { + stats.skipped += 1; + inc(stats.skippedStates, behaviorId); + } continue; } if (usesBackgroundBudget && stats.fullBudgetUsed >= stats.fullBudget) { @@ -225,7 +264,15 @@ stats.full += 1; if (cadence.visible) stats.visibleFullUsed += 1; if (usesBackgroundBudget) stats.fullBudgetUsed += 1; - global.TarinaiCreatureRuntime.updateOne(t, runDt, { context }); + // Off-screen creatures used to receive the accumulated AI/timer dt but + // only one rendered-frame worth of movement (about 0.016s). At high + // zoom this effectively froze creatures after they left the viewport. + // Catch up a bounded amount of motion on their scheduled full update; + // the movement step uses the light collision path for this catch-up. + const runContext = cadence.visible + ? context + : { ...context, motionDt: Math.min(runDt, cadence.near ? 0.45 : 0.75), offscreenCatchup: true }; + global.TarinaiCreatureRuntime.updateOne(t, runDt, { context: runContext }); } else if (cadence.smoothMotion) { if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) { stats.visibleMotionSkips += 1; diff --git a/js/simulation_effects_system.js b/js/simulation_effects_system.js index 275f36f..c8cf771 100644 --- a/js/simulation_effects_system.js +++ b/js/simulation_effects_system.js @@ -60,8 +60,22 @@ return true; } + function clearDisabledEffects(worldRef, effects) { + if (!effects?.length) return; + for (const effect of effects) if (effect) worldRef.releaseEffect?.(effect); + effects.length = 0; + worldRef._effectUpdateCursor = 0; + worldRef._renderEffectCursor = 0; + } + function updateEffects(worldRef, dt) { const effects = worldRef.effects || []; + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") { + clearDisabledEffects(worldRef, effects); + worldRef._effectUpdateStats = null; + return; + } + const beforePressure = effects.length; const pressure = compactEffectPressure(worldRef, effects); const total = effects.length; @@ -69,36 +83,36 @@ worldRef._effectUpdateFrameId = frameId; let lightweight = 0; - const budgeted = []; + let active = 0; for (const effect of effects) { if (!effect || effect.dead) continue; if (effect.type === "ring") { effect.life -= dt; effect._effectUpdatedFrame = frameId; lightweight += 1; - } else { - budgeted.push(effect); - } + } else active += 1; } - const active = budgeted.length; const budget = effectUpdateBudgetFor(active); let updated = 0; let runScale = 1; if (budget >= active) { - for (const ef of budgeted) { - if (updateEffect(ef, dt, frameId, 1)) updated += 1; + for (const effect of effects) { + if (!effect || effect.dead || effect.type === "ring") continue; + if (updateEffect(effect, dt, frameId, 1)) updated += 1; } } else if (active > 0) { + // Walk the source array directly instead of allocating a temporary + // budgeted-effects array every animation frame. runScale = Math.min(3.0, Math.max(1, active / Math.max(1, budget))); - let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % active; + let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % Math.max(1, effects.length); let scanned = 0; - while (updated < budget && scanned < active) { - const ef = budgeted[cursor]; - cursor = (cursor + 1) % active; + while (updated < budget && scanned < effects.length) { + const effect = effects[cursor]; + cursor = (cursor + 1) % Math.max(1, effects.length); scanned += 1; - if (!ef || ef.dead) continue; - updateEffect(ef, dt, frameId, runScale); + if (!effect || effect.dead || effect.type === "ring") continue; + updateEffect(effect, dt, frameId, runScale); updated += 1; } worldRef._effectUpdateCursor = cursor; diff --git a/js/simulation_maintenance_system.js b/js/simulation_maintenance_system.js index fdaa012..d3b642c 100644 --- a/js/simulation_maintenance_system.js +++ b/js/simulation_maintenance_system.js @@ -12,7 +12,7 @@ worldRef._nextItemCompactAt = now + 1.65; worldRef._nextAntCompactAt = now + 3.35; worldRef._nextEffectCountRefreshAt = now + 2.15; - worldRef._nextFamilyPruneAt = now + 9.1; + worldRef._nextTransientRuntimePruneAt = now + 4.75; if (!Number.isFinite(worldRef.nextGrassLimitCheckAt) || worldRef.nextGrassLimitCheckAt <= now) worldRef.nextGrassLimitCheckAt = now + 1.25; if (!Number.isFinite(worldRef.nextFireCompactAt) || worldRef.nextFireCompactAt <= now) worldRef.nextFireCompactAt = now + 0.45; if (!Number.isFinite(worldRef.nextGrassBedExpiryCheckAt) || worldRef.nextGrassBedExpiryCheckAt <= now) worldRef.nextGrassBedExpiryCheckAt = now + 2.85; @@ -50,12 +50,42 @@ worldRef._nextAntCompactAt = now + 4.3; worldRef.compactAnts?.(); } - if (worldRef.familyPrunePending && now >= (worldRef._nextFamilyPruneAt || 0)) { - worldRef._nextFamilyPruneAt = now + 8.0; - worldRef.familyPrunePending = false; - worldRef.pruneExtinctFamilies?.(); + // Runtime-only collision/social dedupe caches must expire independently of + // deaths. Stable live population does not imply stable entity identities, and + // the old death-triggered cleanup let these collections grow for many seasons. + if (now >= (worldRef._nextTransientRuntimePruneAt || 0)) { + worldRef._nextTransientRuntimePruneAt = now + 5.0; + if (worldRef.ballCollisionMemo instanceof Map) { + for (const [key, at] of worldRef.ballCollisionMemo) { + if (now - Number(at || 0) > 2.0) worldRef.ballCollisionMemo.delete(key); + } + } + if (worldRef.tarinaiCollisionMemo instanceof Map) { + for (const [key, at] of worldRef.tarinaiCollisionMemo) { + if (now - Number(at || 0) > 2.0) worldRef.tarinaiCollisionMemo.delete(key); + } + } + if (worldRef.relationNotices && typeof worldRef.relationNotices === "object") { + for (const [key, at] of Object.entries(worldRef.relationNotices)) { + if (now - Number(at || 0) > 120) delete worldRef.relationNotices[key]; + } + } + if (worldRef.fightPairCooldowns && typeof worldRef.fightPairCooldowns === "object") { + for (const [key, until] of Object.entries(worldRef.fightPairCooldowns)) { + if (Number(until || 0) <= now) delete worldRef.fightPairCooldowns[key]; + } + } + if (worldRef.resolvedFightIds && typeof worldRef.resolvedFightIds === "object") { + for (const [key, at] of Object.entries(worldRef.resolvedFightIds)) { + if (!Number.isFinite(Number(at)) || now - Number(at) > 8.0) delete worldRef.resolvedFightIds[key]; + } + } } + // Family graph normalization/component pruning is intentionally deferred to + // the family view. It is unrelated to simulation correctness and its full- + // graph sweep becomes increasingly expensive as generations accumulate. + const itemCountChanged = worldRef.items.length !== itemCountBefore || worldRef.items.length !== (worldRef._lastMaintenanceItemCount ?? worldRef.items.length); const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length); if (itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) { diff --git a/js/sound_pack.js b/js/sound_pack.js index 31cc592..26e78a7 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -7,6 +7,7 @@ categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, idGain: { sfx_notify: 2.60, + sfx_achievement_unlock: 1.00, sfx_ui_click: 2.35, sfx_ui_fold: 2.20, sfx_place: 2.10, @@ -36,6 +37,7 @@ sfx_zunchi: 0.14, sfx_ant_die: 0.14, sfx_notify: 0.10, + sfx_achievement_unlock: 0.35, voice_sunbath: 0.95, sfx_shoot: 0.045, sfx_bolt_action: 0.10, @@ -62,7 +64,8 @@ voice_major_damage_03: "assets/sounds/major_damage_03.wav", voice_major_damage_04: "assets/sounds/major_damage_04.wav", voice_temp_cold: "assets/sounds/voice_008_temperature_buruburu.wav", - voice_temp_hot: "assets/sounds/voice_009_temperature_achui.wav" + voice_temp_hot: "assets/sounds/voice_009_temperature_achui.wav", + sfx_achievement_unlock: "assets/sounds/achievement_unlock.mp3" }, categories: { voice: "\u305f\u308a\u306a\u3044\u306e\u58f0", @@ -76,7 +79,7 @@ sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice", sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice", sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice", - sfx_notify: "notify", sfx_phase: "notify", + sfx_notify: "notify", sfx_phase: "notify", sfx_achievement_unlock: "notify", sfx_ui_click: "ops", sfx_ui_fold: "ops", sfx_place: "ops", sfx_delete: "ops", sfx_poke: "ops", sfx_grab: "ops", sfx_drop: "ops", sfx_water_hose: "ops", sfx_drink: "ops", sfx_ball_hit: "ops", sfx_stone_impact: "ops", sfx_firecracker_fuse: "ops", sfx_firecracker_explode: "ops", diff --git a/js/structures.js b/js/structures.js index 41482ff..3425936 100644 --- a/js/structures.js +++ b/js/structures.js @@ -214,7 +214,7 @@ world?.markItemBucketsDirty?.("plushie-fling"); world?.markSpatialDirty?.("plushie-fling"); if (world) world.drawListDirty = true; - if (options.log !== false) world?.log?.("\u306C\u3044\u3050\u308B\u307F\u304C\u56DE\u8EE2\u3057\u306A\u304C\u3089\u98DB\u3093\u3067\u3044\u3063\u305F\u3002", "observe", { eventType: "plushie_fling", participants: owner ? [owner] : [] }); + // Player-triggered plushie flings are intentionally excluded from the event log. return true; } diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index 154b8a7..2e9726e 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -423,7 +423,7 @@ function createApproachMateActionSpec() { phrase: "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", weight: 48, selectTarget(t, world) { return selectMateTarget(t, world, 520); }, - canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + canStart(t, world, ctx = {}) { return !t.juvenile && (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, start(t, world, ctx = {}) { const other = ctx.target ?? this.selectTarget(t, world, ctx); if (!other) return false; diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index ab75305..f44d99e 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -230,6 +230,7 @@ function continueBuildPlan(t, world, dt) { structure.use?.(t, world); forceBuiltStructureVisualRefresh(world, structure, `build:${plan.type}:visual-refresh`); t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); + world.log?.(`${t.name}\u304c${label}\u3092\u4f5c\u3063\u305f\u3002`, "event", { participants: [t] }); applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); t.buildPlan = null; return true; diff --git a/js/tarinai_cursor_care_system.js b/js/tarinai_cursor_care_system.js index f0513d7..b1bf01c 100644 --- a/js/tarinai_cursor_care_system.js +++ b/js/tarinai_cursor_care_system.js @@ -18,10 +18,12 @@ this.goodMode = "smile"; if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); - for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { - this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { - vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" - })); + if (typeof this.world?.queueEffect === "function") { + for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { + this.world.queueEffect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { + vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" + }); + } } } diff --git a/js/tarinai_direct_feeding_system.js b/js/tarinai_direct_feeding_system.js index d7c606f..2d61e66 100644 --- a/js/tarinai_direct_feeding_system.js +++ b/js/tarinai_direct_feeding_system.js @@ -60,7 +60,7 @@ const radius = Math.max(18, Number(tarinai.radius || 22) || 22); for (let i = 0; i < count; i += 1) { const side = i % 2 ? 1 : -1; - worldRef.effects.push(new global.TarinaiEffect("heart", + 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), @@ -68,11 +68,11 @@ size: global.rand(9, 17), life: global.rand(0.82, 1.36), color: "rgba(240,91,135,0.94)", - })); + }); } - worldRef.effects.push(new global.TarinaiEffect("ring", tarinai.x, tarinai.y, { + worldRef.queueEffect?.("ring", tarinai.x, tarinai.y, { size: Math.max(24, radius * 1.35), life: 0.28, color: "rgba(240,91,135,0.42)", - })); + }); return true; } @@ -138,10 +138,7 @@ global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, beneficialRecovery, source: options.source || "direct" }); } - const sourceLabel = options.source === "pinch" ? "\u624b\u6e21\u3057\u3067" : "\u76f4\u63a5"; - worldRef.log?.(`${tarinai.name}\u306b${label}\u3092${sourceLabel}\u4e0e\u3048\u305f\u3002`, "food", { - participants: [tarinai], eventType: "direct_feed", sound: false, - }); + // Direct feeding is a player operation and is intentionally not logged. const removed = options.removeItem !== false ? removeGivenWorldItem(worldRef, item, label) : false; if (!removed && (worldRef.items || []).includes(item)) { diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index a8b51a1..b24e155 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -290,7 +290,7 @@ let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; const now = this.world?.time || 0; if (!alreadyInside && occupants.length >= capacity) { - const kingSleepDisplace = Boolean(this.isTarinaiChampion && box.type === "nest_box" && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping)); + const kingSleepDisplace = Boolean(this.isTarinaiChampion && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping)); if (options?.forceDisplace || kingSleepDisplace) { const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion) || occupants.find(t => t && t !== this && !t.dead); @@ -301,6 +301,9 @@ victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`; victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65); victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`); + if (this.isTarinaiChampion) { + this.world?.log?.(`${this.name}\u304c${containerLabel(box)}\u306b\u5165\u308a\u3001${victim.name}\u3092\u62bc\u3057\u51fa\u3057\u305f\u3002`, "event", { participants: [this, victim] }); + } occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim); } } @@ -595,13 +598,14 @@ this.burnWanderAngle = Number.isFinite(this.burnWanderAngle) ? this.burnWanderAngle : deterministicAngle(this.world, "tarinai-burn-angle", this, source || null); if (typeof this.enterPanic === "function") this.enterPanic({ threat: source, target: null, reason: text, fear: 2.2, wake: true, cause: "burning", surpriseTimer: 0.65, awakeLockTimer: 0.8 }); else this.setActionState?.("panic", { target: null, reason: text, wake: true }); - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(26, this.radius * 1.35), life: 0.26, color: "rgba(255,110,36,0.58)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(26, this.radius * 1.35), life: 0.26, color: "rgba(255,110,36,0.58)" }); const now = Number(this.world?.time || 0); if (force || !already || now >= Number(this.nextBurnVoiceAt || -999)) { this.nextBurnVoiceAt = now + 2.6; this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true }); } this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a"); + if (!already) this.world?.log?.(`${this.name}\u306b\u706b\u304c\u71c3\u3048\u79fb\u3063\u305f\u3002`, "accident", { participants: [this] }); if (ignitedDuringBirthRitual) globalThis.TarinaiAchievements?.recordIgnition?.({ world: this.world, tarinai: this, source }); return true; }, @@ -614,7 +618,8 @@ this.firePanicTimer = Math.max(this.firePanicTimer || 0, 0.25); this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35); if (reason) this.thought = reason; - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" }); + this.world?.log?.(`${this.name}\u306e\u706b\u304c\u6d88\u3048\u305f\u3002`, "accident", { participants: [this] }); return true; }, @@ -689,9 +694,9 @@ if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); if (deterministicChance(this.world, "explosion-disease-spark", dt * 2.1, this)) { const a = deterministicAngle(this.world, "explosion-disease-spark-angle", this); - this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), { + this.world?.queueEffect?.("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), { vx: Math.cos(a) * deterministicRange(this.world, "explosion-disease-spark-vx", 10, 38, this), vy: Math.sin(a) * deterministicRange(this.world, "explosion-disease-spark-vy", 10, 38, this) - deterministicRange(this.world, "explosion-disease-spark-up", 8, 22, this), size: deterministicRange(this.world, "explosion-disease-spark-size", 5, 12, this), life: deterministicRange(this.world, "explosion-disease-spark-life", 0.18, 0.42, this), color: "rgba(255,64,48,0.86)" - })); + }); } if (this.explosionDiseaseTimer <= 0.01) { this.world?.explodeDiseaseTarinai?.(this); diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 0734130..7a0757c 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -78,11 +78,19 @@ syncGrowthScale() { if (!Number.isFinite(this.adultScale)) this.adultScale = adultScaleFromGenetics(this.familyKey || this.id, this.genetics); - const nextScale = this.generation > 1 ? scaleForGrowth(this.adultScale, this.growth) : this.adultScale; + const currentGrowth = this.growth; + const isJuvenileNow = this.generation > 1 && currentGrowth < 1; + if (this.generation > 1) { + if (this._growthWasJuvenile === true && !isJuvenileNow) { + this.world?.log?.(`${this.name}\u304c\u6210\u9577\u3057\u3066\u6210\u4f53\u306b\u306a\u3063\u305f\u3002`, "birth", { participants: [this] }); + } + this._growthWasJuvenile = isJuvenileNow; + } + const nextScale = this.generation > 1 ? scaleForGrowth(this.adultScale, currentGrowth) : this.adultScale; this.scale = clamp(nextScale, 0.10, 0.42); if (this.refreshEffectiveSize) this.refreshEffectiveSize(); else this.radius = 56 * this.scale; - const growthBucket = Math.floor(clamp(this.growth || 0, 0, 1) * 20); + const growthBucket = Math.floor(clamp(currentGrowth || 0, 0, 1) * 20); if (this.generation > 1 && this.world?.recordFamily && this.familyJoined?.() && this.familyGrowthBucket !== growthBucket) { this.familyGrowthBucket = growthBucket; this.world.recordFamily(this); @@ -257,13 +265,15 @@ adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { if (!other || !other.id || other === this) return; const rel = this.relationTo(other.id); + const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; + const reverseBefore = other.relationships?.[this.id] || null; + const wasFriend = Math.max(rel.affinity || 0, reverseBefore?.affinity || 0) >= threshold; rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); rel.fightsWon = rel.fightsWon || 0; rel.fightsLost = rel.fightsLost || 0; if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } this.relationCache = null; - const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; if (affinityDelta > 0 && rel.affinity >= threshold && other.relationTo) { const reverse = other.relationTo(this.id); const mirrored = Math.max(reverse.affinity || 0, threshold + 0.25, Math.min(rel.affinity, rel.affinity * 0.78)); @@ -272,6 +282,11 @@ if (event && !reverse.lastEvent) { reverse.lastEvent = event; reverse.lastTime = this.world?.time || other.world?.time || 0; } other.relationCache = null; } + const reverseAfter = other.relationships?.[this.id] || null; + const isFriend = Math.max(rel.affinity || 0, reverseAfter?.affinity || 0) >= threshold; + if (!wasFriend && isFriend) { + this.world?.log?.(`${this.name}\u3068${other.name}\u306f\u53cb\u9054\u306b\u306a\u3063\u305f\u3002`, "relation", { participants: [this, other] }); + } }, strongestRelation(kind = "friend", liveOnly = true) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index b5aaac7..ad21ec1 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -115,7 +115,13 @@ function updateNeeds(tarinai, world, dt = 0) { ? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null) : null; if (championMate) { socialParts.mate += 48; socialParts.championMate = 1; raw.social += 48; } - else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; } + else if (!tarinai.juvenile && (tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired && (world?.canAddTarinai?.(1) ?? true)) { + // Mating is its own relationship sub-need. It must remain actionable even + // after ordinary loneliness has been relieved by a larger colony; otherwise + // only the initial lonely generation ever seeks a partner. + socialParts.mate += 28 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); + raw.social += socialParts.mate; + } const enemy = tarinai.strongestRelation?.("fear"); if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } const conflict = evaluateConflictUrge(tarinai, world, 112); @@ -334,9 +340,17 @@ function shouldStartActionBySubNeed(tarinai, action, needs) { if (subValue >= threshold) return true; return isContinuingSameAction && subValue >= threshold * 0.55; } - if (subNeed === "mate" || subNeed === "conflict") { - const relationDrive = Math.max(Number(parts.mate || 0) || 0, Number(parts.conflict || 0) || 0, compatibleSocialTotal * 0.36); - const drugBoosted = (tarinai?.loveMochiTimer || 0) > 0.04 || (tarinai?.fightMochiTimer || 0) > 0.04; + if (subNeed === "mate") { + // A ready mating drive is not the same thing as loneliness. Requiring the + // aggregate social need to exceed 52 made reproduction disappear once a + // colony became socially dense, even though new unpaired adults were ready. + if (subValue >= threshold) return true; + if (isContinuingSameAction && subValue >= threshold * 0.55) return true; + return (tarinai?.loveMochiTimer || 0) > 0.04 && subValue > 0; + } + if (subNeed === "conflict") { + const relationDrive = Math.max(Number(parts.conflict || 0) || 0, compatibleSocialTotal * 0.36); + const drugBoosted = (tarinai?.fightMochiTimer || 0) > 0.04; return compatibleSocialTotal >= needThreshold("social", "start") && (drugBoosted || relationDrive >= Math.min(socialSubNeedStartThreshold("mate"), socialSubNeedStartThreshold("conflict")) * 0.48); } if (subValue >= threshold) return true; diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index 4fb5cdd..6856850 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -35,15 +35,16 @@ function nestBoxCapacity(worldRef, box = null) { if (typeof worldRef?.nestBoxCapacity === "function") return worldRef.nestBoxCapacity(box); - if (box?.type === "pipe") return Math.max(3, pipeItems(worldRef).length * 3); + if (box?.type === "pipe") return 3; return 5; } function nestBoxOccupants(worldRef, box, limit = 5) { if (!worldRef || !box || box.dead || !isNestContainer(box)) return []; - const occupants = box.type === "pipe" - ? (worldRef.tarinai || []).filter(t => tarinaiInsidePipeNetwork(worldRef, t)) - : (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); + // Each pipe mouth has its own local sleeping capacity. The underground + // network is still shared for exits, but occupancy/reservations must stay + // attached to the entrance pipe or every tarinai piles into the nearest one. + const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; } @@ -65,9 +66,7 @@ if (box.type === "pipe" && t.insideNestBoxId && tarinaiInsidePipeNetwork(t.world || null, t)) return false; if (box.type !== "pipe" && t.insideNestBoxId === box.id) return false; const target = tarinaiTargetContainer(t); - if (box.type === "pipe") { - if (target?.type !== "pipe") return false; - } else if (target !== box) return false; + if (target !== box) return false; if (t._nestBoxAvoidId === box.id && now < (t._nestBoxAvoidUntil || -Infinity)) return false; const seekingNest = t.state === "seek_bed" || t.state === "sleep" || t.sleeping || t.state === "seek_temperature" || t.behavior?.actionId === "sleep_in_bed"; if (!seekingNest) return false; @@ -159,7 +158,12 @@ const d = distFn(tarinai, item); if (d > maxDist) continue; const comfort = bedComfort(worldRef, item); - const score = d - comfort * 18; + const occupancy = bedOccupancy(worldRef, item); + const pipeLoadPenalty = type === "pipe" ? occupancy * 44 : 0; + const spread = type === "pipe" && typeof global.stableUnit === "function" + ? global.stableUnit(tarinai?.id || tarinai?.familyKey || "tarinai", `pipe-choice:${item.id || "pipe"}`) * 14 + : 0; + const score = d - comfort * 18 + pipeLoadPenalty + spread; if (score < bestScore) { best = item; bestScore = score; } } return best; diff --git a/js/tarinai_render.js b/js/tarinai_render.js index c37bf6e..b24e4b3 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -275,8 +275,12 @@ function tarinaiSpriteReversesFacing(sprite) { drawH *= 1.32; } if (sprite === "birth_ritual") { - drawW *= 0.64; - drawH *= 0.64; + // The ritual source image has a larger source canvas than ordinary tarinai + // sprites. Normalize its displayed bounds to the regular smile sprite so + // entering the ritual does not make the actor visibly grow. + const normalMetrics = getImageMetrics("smile") || { w: 512, h: 468 }; + drawW = (normalMetrics.w || 512) * visualScale * 0.30; + drawH = (normalMetrics.h || 468) * visualScale * 0.30; } if (fallPose > 0) { drawW *= 1 + fallPose * 0.08; diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index bc1a455..c05b72a 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -6,7 +6,7 @@ function mateTargetFor(t, world, maxDist = 520) { if (!t || !world) return null; const current = currentBehaviorTarget(t) || t.target; - const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; + const ok = o => o && o !== t && !o.dead && !t.juvenile && !o.juvenile && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; const championOk = o => ok(o) && !!t.isTarinaiChampion && !!o.isTarinaiChampion; if (championOk(current) && dist(t, current) <= maxDist + 260) return current; if (t.isTarinaiChampion) { @@ -61,6 +61,7 @@ function socialFightMateWeights(t, other, world, options = {}) { : (loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44)); const lonelyBond = championPair || (loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24)); const mateEligible = sameStatus + && !t.juvenile && !other.juvenile && timerOk && mateDistanceOk && !world.areParentChild?.(t, other) diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 835cd05..02a4330 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -25,6 +25,7 @@ const ROUTE_STATES = new Set([ "seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature", + "wander", "panic", ]); function isNestContainerItem(item) { @@ -199,19 +200,24 @@ const scanLimit = (this.fightTimer > 0.04 || this.birthRitualTimer > 0.04) ? 12 : 8; const selfRadius = Math.max(12, Number(this.radius || 22) || 22); const scanRadius = Math.max(92, selfRadius * 2.0 + 52); + const candidateBudget = Math.max(20, scanLimit + 8); const selfProfile = this.personalityProfile(); const detailed = []; - // nearbyTarinai() has already paid the spatial lookup cost and returns only - // candidates inside the requested radius when precise=true. Do not stop at - // an arbitrary iteration count here: the incremental grid intentionally does - // not preserve bucket insertion order, so an early break can miss the actual - // nearest mate/contact candidates after enough cell crossings. Keep only the - // nearest scanLimit entries with a tiny bounded insertion list instead. + let inspected = 0; + const currentBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : null; + const currentMateTarget = (currentBehavior?.actionId === "approach_mate" || this.actionId === "approach_mate") + ? (currentBehavior?.target || this.target || null) + : null; + // Contact physics/social work must stay bounded in dense colonies. Scan a + // small candidate budget, retain the nearest entries, and explicitly add the + // already-selected mate target so reproduction does not depend on spatial + // bucket insertion order. for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) { if (o === this || o.dead) continue; const dx = o.x - this.x, dy = o.y - this.y; const d2 = dx * dx + dy * dy; if (d2 > scanRadius * scanRadius || d2 < 0.0001) continue; + inspected += 1; const entry = { o, dx, dy, d2 }; let pos = detailed.length; while (pos > 0 && detailed[pos - 1].d2 > d2) pos--; @@ -219,6 +225,19 @@ detailed.splice(pos, 0, entry); if (detailed.length > scanLimit) detailed.pop(); } + if (inspected >= candidateBudget) break; + } + if (currentMateTarget && currentMateTarget !== this && !currentMateTarget.dead) { + let alreadyIncluded = false; + for (const entry of detailed) { + if (entry.o === currentMateTarget) { alreadyIncluded = true; break; } + } + if (!alreadyIncluded) { + const dx = currentMateTarget.x - this.x; + const dy = currentMateTarget.y - this.y; + const d2 = dx * dx + dy * dy; + if (d2 > 0.0001 && d2 <= scanRadius * scanRadius) detailed.push({ o: currentMateTarget, dx, dy, d2 }); + } } for (const entry of detailed) { const o = entry.o; @@ -251,9 +270,6 @@ avoider.surpriseTimer = Math.max(avoider.surpriseTimer || 0, 0.20); avoider.fearTimer = Math.max(avoider.fearTimer || 0, 0.32 * avoider.personalityProfile().fear); if (typeof applyNeedShock === "function") applyNeedShock(avoider, { safety: dt * 26, social: dt * 5 }, champion); - if (this.world.relationNotice?.(avoider.id, champion.id, "tarinai-champion-avoid", 18)) { - this.world.log(`${avoider.name}\u306F\u305F\u308A\u306A\u3044\u738B\u8005\u306E${champion.name}\u306B\u3076\u3064\u304B\u308A\u305D\u3046\u306B\u306A\u3063\u3066\u907F\u3051\u305F\u3002`, "relation", { participants: [avoider, champion], eventType: "avoid_relationship", sound: false }); - } } forceKingFlee(avoider, champion, dt); avoider.adjustRelation?.(champion, -dt * 0.025, dt * 0.18, "tarinai_champion_avoid"); diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index d66bbf1..637fe39 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -25,7 +25,8 @@ const minute = dt / 60; t.age += dt; - t.reproductionTimer -= dt; + // reproductionTimer is advanced in the outer creature pass so low-frequency + // AI updates do not stretch the cooldown as population grows. t.eatTimer = Math.max(0, t.eatTimer - dt); if (t.state === "eat" && t.eatTimer <= 0.02) { t.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); @@ -85,7 +86,6 @@ if (!t.dead) t._achievementLaxativeActiveThisFrame = false; return { done: true }; } - global.TarinaiCursorCareSystem.updateOne(t, dt); const wasFighting = t.fightTimer > 0.04; const endingFightTargetIds = wasFighting ? [...new Set([t.fightTargetId, ...(Array.isArray(t.fightTargetIds) ? t.fightTargetIds : [])].filter(Boolean))] : []; t.fightTimer = Math.max(0, t.fightTimer - dt); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index 579e649..1cfbeb9 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -236,7 +236,8 @@ t._lastPhysicalVelocityY = 0; return { done: false, held: true }; } - const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016)); + const motionDtCap = context.offscreenCatchup ? 0.75 : 0.05; + const motionDt = Math.max(0.001, Math.min(motionDtCap, Number(context.motionDt ?? dt) || 0.016)); if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { return passivePhysicalMotion(t, motionDt, context); } @@ -249,11 +250,12 @@ global.TarinaiSunbathSystem.updateOne(t, motionDt); t.maintainTargetProgress(motionDt); const prevMotionOnlyFlag = t._motionOnlyPhysicsPass; - if (context.motionOnly) t._motionOnlyPhysicsPass = true; + const useLightMotion = Boolean(context.motionOnly || context.offscreenCatchup); + if (useLightMotion) t._motionOnlyPhysicsPass = true; try { t.move(motionDt); } finally { - if (context.motionOnly) t._motionOnlyPhysicsPass = prevMotionOnlyFlag; + if (useLightMotion) t._motionOnlyPhysicsPass = prevMotionOnlyFlag; } if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(motionDt); applyVelocityShockDamage(t, before, motionDt); diff --git a/js/ui.js b/js/ui.js index 48b9fce..695d6eb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -75,8 +75,11 @@ const uiCache = { showSelectedEmpty: false, selectedCollapsedCategories: new Set(), logPushEnabled: false, - logPushKinds: new Set(["death", "accident", "birth"]), + logEventCategories: new Set(["life", "danger", "social", "living", "environment", "observe"]), archiveUpdateEnabled: false, + activePanelTab: "", + archiveViewActivated: false, + panelTabScrollRaf: 0, resetFieldType: "garden", resetPreset: "default", toolButtons: [], diff --git a/js/ui_bind.js b/js/ui_bind.js index 40ab925..3cd3de2 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -37,9 +37,12 @@ function bindUI() { } function setActivePanelTab(tab = "") { + const previous = uiCache.activePanelTab || ""; + uiCache.activePanelTab = tab; for (const btn of ui.panelQuickTabs?.querySelectorAll("[data-panel-tab]") || []) { btn.classList.toggle("active", btn.dataset.panelTab === tab); } + if (previous === "family" && tab !== "family") window.cancelArchiveBackgroundRender?.(); } function isTouchMobileLayout() { @@ -109,6 +112,10 @@ function bindUI() { function scrollPanelToTab(tab = "") { const panel = ui.panel; if (!panel) return false; + // Explicit tab selection wins immediately. In particular, family rendering is + // asynchronous and must not be cancelled by a scroll-sync pass fired by the + // navigation scroll itself before the selected tab has been recorded. + setActivePanelTab(tab); window.TarinaiTooltips.hide(); if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) { uiCache.showSelectedEmpty = true; @@ -119,9 +126,10 @@ function bindUI() { if (!target) return false; target.classList?.remove?.("hidden"); if (tab === "family") { - // \u66F4\u65B0OFF\u3067\u3082\u3001\u30E6\u30FC\u30B6\u30FC\u304C\u5BB6\u7CFB\u56F3\u3078\u79FB\u52D5\u3057\u305F\u6642\u3060\u3051\u73FE\u5728\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u63CF\u753B\u3059\u308B\u3002 - window.resetArchiveRenderState?.(); - renderArchive?.(); + // Family data is expensive to lay out. Build it only after the user + // actually opens the family section, and keep the cache across tab switches. + uiCache.archiveViewActivated = true; + renderArchive?.({ userRequested: true }); } const isDesktop = window.matchMedia?.("(min-width: 981px)")?.matches ?? window.innerWidth >= 981; if (isDesktop && tab !== "game" && tab !== "family") { @@ -132,10 +140,14 @@ function bindUI() { // \u30B9\u30DE\u30DB\u306F\u30DA\u30FC\u30B8\u5168\u4F53\u30B9\u30AF\u30ED\u30FC\u30EB\u3002\u76EE\u7684\u5730UI\u306E\u4E0A\u7AEF\u3092\u753B\u9762\u4E0A\u7AEF\u3078\u5408\u308F\u305B\u308B\u3002 scrollWindowToElement(target, 0, "auto"); } - setActivePanelTab(tab); return true; } + window.scrollToSelectedDataPanel = () => { + renderSelected?.(); + return scrollPanelToTab("selected"); + }; + const activatePanelQuickTab = (e) => { const btn = e.target.closest("[data-panel-tab]"); if (!btn) return; @@ -147,9 +159,9 @@ function bindUI() { scrollPanelToTab(btn.dataset.panelTab || ""); }; + // One physical tap should execute one switch. Click already covers mouse, pen, + // and synthesized touch activation, so parallel pointer/touch handlers are avoided. ui.panelQuickTabs?.addEventListener("click", activatePanelQuickTab); - ui.panelQuickTabs?.addEventListener("pointerup", activatePanelQuickTab, { passive: false }); - ui.panelQuickTabs?.addEventListener("touchend", activatePanelQuickTab, { passive: false }); const applyTemperatureSlider = () => { if (!world || !ui.temperatureSlider) return; @@ -207,7 +219,8 @@ function bindUI() { ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai", "number")); ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object", "number")); - ui.panel?.addEventListener("scroll", () => { + const updateActivePanelTabFromScroll = () => { + uiCache.panelTabScrollRaf = 0; const panel = ui.panel; if (!panel || !ui.panelQuickTabs) return; const tabsH = ui.panelQuickTabs.offsetHeight || 0; @@ -220,12 +233,29 @@ function bindUI() { ["family", document.querySelector(".lineage-main-card")], ].filter(([, el]) => el && !el.classList.contains("hidden")); let active = entries[0]?.[0] || ""; - const y = panel.scrollTop + tabsH + 18; - for (const [key, el] of entries) { - if (el.offsetTop <= y) active = key; + const threshold = Math.max(8, tabsH + 18); + const familyEl = entries.find(([key]) => key === "family")?.[1] || null; + const familyRect = familyEl?.getBoundingClientRect?.() || null; + if (familyRect && familyRect.top <= threshold && familyRect.bottom > threshold) { + active = "family"; + } else if (isTouchMobileLayout()) { + for (const [key, el] of entries) { + if ((el.getBoundingClientRect?.().top ?? Infinity) <= threshold) active = key; + } + } else { + const y = panel.scrollTop + tabsH + 18; + for (const [key, el] of entries) { + if (key !== "family" && el.offsetTop <= y) active = key; + } } setActivePanelTab(active); - }, { passive: true }); + }; + const scheduleActivePanelTabSync = () => { + if (uiCache.panelTabScrollRaf) return; + uiCache.panelTabScrollRaf = requestAnimationFrame(updateActivePanelTabFromScroll); + }; + ui.panel?.addEventListener("scroll", scheduleActivePanelTabSync, { passive: true }); + window.addEventListener("scroll", scheduleActivePanelTabSync, { passive: true }); syncToolSizeBadges(); setActivePanelTab(ui.selectedCard?.classList.contains("hidden") ? "tools" : "selected"); @@ -401,13 +431,17 @@ function bindUI() { showToast(uiCache.logPushEnabled ? "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); }); ui.logPushControls?.addEventListener("change", (e) => { - const input = e.target.closest("[data-push-kind]"); + const input = e.target.closest("[data-event-category]"); if (!input) return; - const kind = input.dataset.pushKind || ""; + const category = input.dataset.eventCategory || ""; audio.uiClick?.(); - if (input.checked) uiCache.logPushKinds.add(kind); - else uiCache.logPushKinds.delete(kind); + if (input.checked) uiCache.logEventCategories.add(category); + else uiCache.logEventCategories.delete(category); saveLogPushSettings(); + renderLog(world.logs); + if (!input.checked) { + for (const toast of ui.logPushOverlay?.querySelectorAll?.(`[data-event-category="${category}"]`) || []) toast.remove(); + } }); ui.soundCategoryControls?.addEventListener("change", (e) => { const input = e.target.closest("[data-sound-category]"); @@ -449,6 +483,7 @@ function bindUI() { if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { uiCache.archiveAutoRefreshTimer = window.setInterval(() => { if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; + if (uiCache.activePanelTab !== "family") return; if (typeof lineageArchiveNeedsRender === "function" && !lineageArchiveNeedsRender()) return; scheduleArchiveWindowRender(); }, 5000); diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 6b54f19..25f18d5 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -134,7 +134,10 @@ function lineageFamilyHtml(component, index, family, signature = "") { function lineageArchiveNeedsRender() { const familyVersion = world.familyVersion || 0; const familyDirty = Boolean(world.familyTreeDirty); - return !uiCache.archiveVersion || uiCache.archiveFamilyVersion !== familyVersion || familyDirty; + // A cancelled/cleared DOM must never make the family view permanently blank + // merely because the cached data version still matches the world. + const hasSnapshot = Boolean(ui.archiveContent?.firstElementChild); + return !hasSnapshot || !uiCache.archiveVersion || uiCache.archiveFamilyVersion !== familyVersion || familyDirty; } function lineageDeferArchiveIfOffscreen(hiddenInterval = 30000) { @@ -156,12 +159,18 @@ function lineageScheduleArchiveIdle(timeout = 1200) { return true; } -function renderArchive() { +function renderArchive(options = {}) { if (!ui.archiveContent) return; + const userRequested = Boolean(options?.userRequested); + if (!userRequested && uiCache.activePanelTab !== "family") { + uiCache.archiveScheduled = false; + return; + } + if (userRequested) uiCache.archiveViewActivated = true; // Auto update OFF now means "do not keep refreshing", not "hide the tree". // Draw the current snapshot once so the family tree is always available under // the event UI, then mark it stale when the family data changes. - if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + if (!userRequested && typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { uiCache.archiveScheduled = false; if (uiCache.archiveVersion && world.familyTreeDirty) { const hasRenderedGraph = Boolean(ui.archiveContent.querySelector(".lineage-family-tree, [data-family-tarinai-id]")); @@ -187,10 +196,10 @@ function renderArchive() { return; } if (!lineageArchiveNeedsRender()) return; - if (lineageDeferArchiveIfOffscreen()) return; + if (!userRequested && lineageDeferArchiveIfOffscreen()) return; const now = performance.now(); const minUpdateGap = 4800; - if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { + if (!userRequested && uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { if (!uiCache.archiveRenderTimer) { uiCache.archiveRenderTimer = setTimeout(() => { uiCache.archiveRenderTimer = 0; @@ -315,6 +324,10 @@ function resetArchiveRenderState() { } function scheduleArchiveWindowRender() { + if (!uiCache.archiveViewActivated || uiCache.activePanelTab !== "family") { + uiCache.archiveScheduled = false; + return; + } if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { uiCache.archiveScheduled = false; if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); @@ -331,6 +344,7 @@ function scheduleArchiveWindowRender() { } function schedulePendingArchiveRender() { + if (!uiCache.archiveViewActivated || uiCache.activePanelTab !== "family") return; if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; if (!uiCache.archivePendingOffscreen) return; if (!lineageArchiveNearViewport()) return; @@ -338,4 +352,27 @@ function schedulePendingArchiveRender() { if (lineageArchiveNeedsRender()) scheduleArchiveWindowRender(); } +function cancelArchiveBackgroundRender() { + const wasRunning = Boolean(uiCache.archiveRenderRunning); + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + // All asynchronous family-layout pumps validate this token before doing work. + // Invalidating it stops an in-progress offscreen build without discarding the + // reusable component HTML cache. An interrupted render is marked incomplete so + // the next explicit visit resumes with a clean render pass. + uiCache.archiveRenderToken = `paused:${performance.now()}`; + uiCache.archiveRenderRunning = false; + uiCache.archiveScheduled = false; + uiCache.archivePendingOffscreen = false; + uiCache.archivePendingDirtyAfterRun = false; + if (wasRunning) { + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + if (world) world.familyTreeDirty = true; + } +} + window.resetArchiveRenderState = resetArchiveRenderState; +window.cancelArchiveBackgroundRender = cancelArchiveBackgroundRender; diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 4250767..171a08f 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -525,7 +525,6 @@ world.rebuildSpatial?.(); renderStats(); if (removed > 0) { - world.log?.(`\u7bc4\u56f2\u524a\u9664\u3067${removed}\u500b\u6d88\u3057\u305f\u3002`, "observe"); showToast(`${removed}\u500b\u6d88\u3057\u307e\u3057\u305f\u3002`); } return true; diff --git a/js/ui_log.js b/js/ui_log.js index 38f6184..cf03f64 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -2,29 +2,79 @@ function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } +const LOG_EVENT_CATEGORIES = Object.freeze(["life", "danger", "social", "living", "environment", "observe"]); +const LOG_EVENT_CATEGORY_LABELS = Object.freeze({ + life: "\u751f\u547d", + danger: "\u5371\u967a", + social: "\u4ea4\u6d41", + living: "\u751f\u6d3b", + environment: "\u74b0\u5883", + observe: "\u89b3\u5bdf", +}); +const LOG_KIND_TO_EVENT_CATEGORY = Object.freeze({ + birth: "life", + death: "life", + accident: "danger", + danger: "danger", + fight: "danger", + relation: "social", + food: "living", + event: "living", + weather: "environment", + grass: "environment", + observe: "observe", + note: "observe", +}); + +function logEventCategoryForKind(kind = "note") { + return LOG_KIND_TO_EVENT_CATEGORY[String(kind || "note")] || "observe"; +} + +function logEventCategoryLabel(category = "observe") { + return LOG_EVENT_CATEGORY_LABELS[String(category || "observe")] || LOG_EVENT_CATEGORY_LABELS.observe; +} + +function allLogEventCategories() { + return new Set(LOG_EVENT_CATEGORIES); +} + function loadLogPushSettings() { try { - const raw = localStorage.getItem("tarinai_log_push_settings_v2"); - if (!raw) return; - const parsed = JSON.parse(raw); - uiCache.logPushEnabled = Boolean(parsed.enabled); - if (Array.isArray(parsed.kinds)) uiCache.logPushKinds = new Set(parsed.kinds); + const raw = localStorage.getItem("tarinai_event_category_settings_v1"); + if (raw) { + const parsed = JSON.parse(raw); + uiCache.logPushEnabled = Boolean(parsed.pushEnabled ?? parsed.enabled); + if (Array.isArray(parsed.categories)) { + const categories = parsed.categories.filter(category => LOG_EVENT_CATEGORIES.includes(category)); + uiCache.logEventCategories = new Set(categories); + } + return; + } + // Keep the previous push master setting, but start the shared event filters + // with all categories visible so upgrading never hides historical logs. + const legacyRaw = localStorage.getItem("tarinai_log_push_settings_v2"); + if (legacyRaw) { + const legacy = JSON.parse(legacyRaw); + uiCache.logPushEnabled = Boolean(legacy.enabled); + uiCache.logEventCategories = allLogEventCategories(); + saveLogPushSettings(); + } } catch (_) {} } function saveLogPushSettings() { try { - localStorage.setItem("tarinai_log_push_settings_v2", JSON.stringify({ - enabled: Boolean(uiCache.logPushEnabled), - kinds: Array.from(uiCache.logPushKinds || []), + localStorage.setItem("tarinai_event_category_settings_v1", JSON.stringify({ + pushEnabled: Boolean(uiCache.logPushEnabled), + categories: Array.from(uiCache.logEventCategories || []), })); } catch (_) {} } function syncLogPushControls() { if (ui.logPushEnabled) ui.logPushEnabled.checked = Boolean(uiCache.logPushEnabled); - for (const input of ui.logPushControls?.querySelectorAll("[data-push-kind]") || []) { - input.checked = uiCache.logPushKinds?.has(input.dataset.pushKind || "") ?? false; + for (const input of ui.logPushControls?.querySelectorAll("[data-event-category]") || []) { + input.checked = uiCache.logEventCategories?.has(input.dataset.eventCategory || "") ?? false; } } @@ -57,15 +107,17 @@ function toggleSoundPanel() { function pushLogNotification(entry = {}) { if (!uiCache.logPushEnabled || !entry || entry.hiddenFromObservation) return; const kind = entry.kind || "note"; - if (!uiCache.logPushKinds?.has(kind)) return; + const category = logEventCategoryForKind(kind); + if (!uiCache.logEventCategories?.has(category)) return; const overlay = ui.logPushOverlay; if (!overlay) return; audio.notify?.(); const card = document.createElement("div"); - card.className = `log-push-toast log-push-${kind}`; + card.className = `log-push-toast log-push-${category}`; + card.dataset.eventCategory = category; const tag = document.createElement("span"); tag.className = "log-push-tag"; - tag.textContent = logKindLabel(kind); + tag.textContent = logEventCategoryLabel(category); const time = document.createElement("span"); time.className = "log-push-time"; time.textContent = formatLogTime(entry.time || 0); @@ -91,7 +143,8 @@ function shouldShowInEventLog(entry = {}) { const text = String(entry.text || ""); if (type === "player_item_placement" || type.includes("player") || type.includes("tool") || type.includes("robot")) return false; if (/\u30ed\u30dc\u6383\u9664\u6a5f|\u6383\u9664\u6a5f/.test(text)) return false; - return true; + const category = logEventCategoryForKind(entry.kind || "note"); + return uiCache.logEventCategories?.has(category) ?? true; } function renderLog(logs) { @@ -105,11 +158,13 @@ function renderLog(logs) { const rows = visibleLogs.slice(0, limit); for (const entry of rows) { const kind = entry.kind || "note"; + const category = logEventCategoryForKind(kind); const div = document.createElement("div"); - div.className = `log-entry log-${kind}`; + div.className = `log-entry log-${category}`; + div.dataset.eventCategory = category; const tag = document.createElement("span"); tag.className = "log-tag"; - tag.textContent = logKindLabel(kind); + tag.textContent = logEventCategoryLabel(category); const time = document.createElement("span"); time.className = "time"; time.textContent = formatLogTime(entry.time || 0); @@ -132,10 +187,7 @@ function formatLogTime(timeValue) { } function logKindLabel(kind) { - return { - birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349", - weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", event: "\u51fa\u6765\u4e8b", note: "\u8a18\u9332" - }[kind] || "\u8a18\u9332"; + return logEventCategoryLabel(logEventCategoryForKind(kind)); } function tarinaiSpritePathForId(id) { diff --git a/js/version.js b/js/version.js index b56b766..cfb0386 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.16.98"; - const APP_BUILD = "tarinai-ui-v39-16-95"; + const APP_VERSION = "39.16.109"; + const APP_BUILD = "tarinai-ui-v39-16-109"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/weather_system.js b/js/weather_system.js index 3b008f5..db8233a 100644 --- a/js/weather_system.js +++ b/js/weather_system.js @@ -25,13 +25,13 @@ return fixed || null; } - function enforceFixedWeather(worldRef) { + function enforceFixedWeather(worldRef, options = {}) { const fixed = fixedWeatherFor(worldRef); if (!fixed || !worldRef) return false; const previous = canonicalWeather(worldRef.weather || "sunny"); worldRef.weather = canonicalWeather(fixed); worldRef.nextWeatherChange = resetTimer(); - if (previous !== worldRef.weather) { + if (previous !== worldRef.weather && options.silentLog !== true) { worldRef.log?.(`\u5929\u6C17: ${weatherLabel(worldRef.weather)}`); } return true; diff --git a/js/world.js b/js/world.js index 786a7b9..df8939d 100644 --- a/js/world.js +++ b/js/world.js @@ -84,6 +84,8 @@ class World { constructor() { this.countsTimer = 0; this.compactTimer = 0; this.drawSortTimer = 0; + this._maintenanceScheduleReady = false; + this._nextTransientRuntimePruneAt = 0; this.drawListDirty = true; this.antUpdatePhase = 0; this.selected = null; @@ -117,6 +119,8 @@ class World { constructor() { this.relationNotices = {}; this.resolvedFightIds = {}; this.fightPairCooldowns = {}; + this.ballCollisionMemo = new Map(); + this.tarinaiCollisionMemo = new Map(); this.deadTarinaiSinceRuntimePrune = 0; this.tarinaiRuntimePrunePending = false; this._deadTarinaiIdsPendingCleanup = new Set(); diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index fd564dc..9918e4b 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -4,16 +4,15 @@ (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_combat_effects.js"); - const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall"]); + const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"]); const EFFECT_POOL_LIMIT_PER_TYPE = 96; const effectPool = new Map(); function acquireEffect(type, x, y, options) { const key = String(type || ""); const bucket = EFFECT_POOL_TYPES.has(key) ? effectPool.get(key) : null; - const effect = bucket?.length ? bucket.pop() : new Effect(key, x, y, options); - if (bucket?.length >= 0) effect.reset(key, x, y, options); - return effect; + if (bucket?.length) return bucket.pop().reset(key, x, y, options); + return new Effect(key, x, y, options); } function releaseEffect(effect) { @@ -28,7 +27,25 @@ } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + queueEffect(type, x, y, options = {}) { + // The visual-effects toggle is a true simulation gate: when disabled, + // do not allocate, enqueue, update, or draw transient Effect objects. + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null; + const opts = options || {}; + const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1)))); + const effect = acquireEffect(type, x, y, opts); + effect._effectImportance = importance; + this.effects.push(effect); + if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) { + this._effectSpawnStats = this._effectSpawnStats || {}; + this._effectSpawnStats.requested = (this._effectSpawnStats.requested || 0) + 1; + if (EFFECT_POOL_TYPES.has(String(type || ""))) this._effectSpawnStats.poolEligible = (this._effectSpawnStats.poolEligible || 0) + 1; + } + return effect; + }, + spawnEffect(type, x, y, options = {}) { + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null; const opts = options || {}; const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1)))); if (importance <= 1 && opts.allowOffscreen !== true) { @@ -45,15 +62,7 @@ return null; } } - const effect = acquireEffect(type, x, y, opts); - effect._effectImportance = importance; - this.effects.push(effect); - if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) { - this._effectSpawnStats = this._effectSpawnStats || {}; - this._effectSpawnStats.requested = (this._effectSpawnStats.requested || 0) + 1; - if (EFFECT_POOL_TYPES.has(String(type || ""))) this._effectSpawnStats.poolEligible = (this._effectSpawnStats.poolEligible || 0) + 1; - } - return effect; + return this.queueEffect(type, x, y, opts); }, releaseEffect(effect) { @@ -463,7 +472,7 @@ if (this.addItem?.(it, "zunchi-burst")) spawned += 1; } if (!spawned) return 0; - this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); + this.queueEffect?.("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }); this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] }); audio.place?.("zunchi"); this.ensureSpatial?.("zunchi-burst"); @@ -677,10 +686,13 @@ resolveFightOutcome(winner, loser) { if (!winner || !loser || winner.dead || loser.dead) return; - const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; + // Keep only one timestamp per directional pair. The old time-bucket key + // created a new permanent property every two seconds for recurring fights. + const key = `${winner.id}:${loser.id}`; if (!this.resolvedFightIds) this.resolvedFightIds = {}; - if (this.resolvedFightIds[key]) return; - this.resolvedFightIds[key] = true; + const resolvedAt = Number(this.resolvedFightIds[key]); + if (Number.isFinite(resolvedAt) && this.time - resolvedAt < 2) return; + this.resolvedFightIds[key] = this.time; winner.defeatedById = null; winner.defeatedTimer = 0; winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); @@ -797,7 +809,6 @@ ball.amount = Math.max(ball.amount || 999, 999); audio.ballHit?.(); this.spawnEffect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" }); - if (!isBalloon) this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" }); return true; }, diff --git a/js/world_environment.js b/js/world_environment.js index 0655c1d..d925aa3 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -398,10 +398,7 @@ nestBoxCapacity(box = null) { - if (box?.type === "pipe") { - const count = (this.items || []).reduce((n, it) => n + (it && !it.dead && it.type === "pipe" ? 1 : 0), 0); - return Math.max(3, count * 3); - } + if (box?.type === "pipe") return 3; return 5; }, @@ -839,7 +836,7 @@ } if (now >= (t.lastBounceFenceEffectAt || -999) + 0.08) { t.lastBounceFenceEffectAt = now; - this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" })); + this.queueEffect?.("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }); } } else if (r.mechanical && opts.pressureCheap) { const omega = r.rotator ? clamp(Number(r.angularVelocity || 0) || 0, -4.5, 4.5) : 0; @@ -1034,15 +1031,24 @@ const cy = (y1 + y2) / 2; const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; const exclude = opts?.exclude || null; + const seenNestBoxes = new Set(); for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) { if (exclude && rect?.item === exclude) continue; if (!this.isFenceRoutingRect?.(rect)) continue; - if (rect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) continue; - if (rect?.oriented) { - const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }; + let routingRect = rect; + const routingItem = rect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + routingRect = this.nestBoxBaseRect?.(routingItem) || rect; + } + if (routingRect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, routingRect)) continue; + if (routingRect?.oriented) { + const r = { ...routingRect, halfW: (routingRect.halfW || 0) + padding, halfH: (routingRect.halfH || 0) + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } else { - const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; + const r = { left: routingRect.left - padding, right: routingRect.right + padding, top: routingRect.top - padding, bottom: routingRect.bottom + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } } @@ -1058,12 +1064,21 @@ pointBlockedByObstacle(x, y, padding = 14, opts = {}) { const exclude = opts?.exclude || null; const searchRadius = Math.max(48, Number(padding || 0) + 72); + const seenNestBoxes = new Set(); for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) { if (exclude && rect?.item === exclude) continue; // Directional pathfinding evaluates one-way fences on edges, not as // permanently blocked grid points. Other callers retain solid occupancy. if (rect?.oneWay && opts?.directionalOneWay === true) continue; - if (this.pointInRect?.(x, y, rect, padding)) return true; + let routingRect = rect; + const routingItem = rect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + routingRect = this.nestBoxBaseRect?.(routingItem) || rect; + } + if (this.pointInRect?.(x, y, routingRect, padding)) return true; } return false; }, diff --git a/js/world_family_social.js b/js/world_family_social.js index 893af98..dc4e921 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -143,6 +143,53 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { return ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `\u6301\u3061\u4e3b: ${owner?.name || "\u4e0d\u660e"}`, worldRef.temperatureTooltipLineFor?.(bed, "\u5468\u8fba\u6c17\u6e29")].filter(Boolean); } + + function pointerObjectTooltipInfoLocal(worldRef) { + const p = worldRef?.pointer; + if (!p?.inside) return null; + const nearby = worldRef.nearbyItems?.(p.x, p.y, 150) || worldRef.nearbyNonGrassItems?.(p.x, p.y, 150) || []; + let best = null; + let bestDistance = Infinity; + const sleepFurniture = global.TarinaiItemToolMetadata?.ITEM_TRAITS?.sleepFurniture || null; + for (const item of nearby) { + if (!item || item.dead || item.type === "trace" || item.type === "splat") continue; + const isSleepFurniture = sleepFurniture?.has?.(item.type) || ["bed", "nest_box", "pipe", "grass_bed"].includes(item.type); + if (!isSleepFurniture) continue; + const isNest = global.TarinaiToolRuntime?.isNestContainerItem?.(item); + const isOwnedBed = item.type === "grass_bed"; + const fallbackDistance = distXY(p.x, p.y, Number(item.x || 0), Number(item.y || 0)); + let hit = false; + let hitDistance = fallbackDistance; + const hitTest = global.TarinaiCollisionFootprints?.hitTestItem; + if (typeof hitTest === "function") { + const result = hitTest(worldRef, item, p.x, p.y, { + padding: worldRef.screenSizeToWorld ? worldRef.screenSizeToWorld(5) : 5, + radiusMultiplier: 1.15, + }) || null; + hit = Boolean(result?.hit); + if (Number.isFinite(Number(result?.distance))) hitDistance = Number(result.distance); + } else { + hit = fallbackDistance <= Math.max(30, (Number(item.r) || 18) * 1.35); + } + if (!hit || hitDistance >= bestDistance) continue; + best = item; + bestDistance = hitDistance; + } + if (!best) return null; + if (global.TarinaiToolRuntime?.isNestContainerItem?.(best)) { + return { target: best, lines: worldRef.nestBoxTooltipLines?.(best) || [], kind: best.type === "pipe" ? "pipe" : "nest_box" }; + } + if (best.type === "grass_bed") { + return { target: best, lines: ownedBedTooltipLinesLocal(worldRef, best), kind: "grass_bed" }; + } + const definition = global.TarinaiToolRuntime?.itemDefinition?.(best.type) || null; + const lines = [definition?.label || String(best.type || "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8")]; + if (definition?.tooltip) lines.push(definition.tooltip); + const temperatureLine = worldRef.temperatureTooltipLineFor?.(best, "\u5468\u8fba\u6c17\u6e29"); + if (temperatureLine && (best.isStructure || best.type === "toilet" || best.type === "rain_shelter")) lines.push(temperatureLine); + return { target: best, lines: lines.filter(Boolean), kind: "object" }; + } + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ reset(seedPopulation = null, fieldType = this.fieldType || "garden", presetId = "default") { const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; @@ -690,17 +737,18 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { }, pointerItemTooltipInfo() { + // Precise object hit-testing comes first; only if no object is actually + // under the pointer do we fall back to the Tarinai tooltip. + const objectInfo = pointerObjectTooltipInfoLocal(this); + if (objectInfo?.target) return objectInfo; const tarinaiInfo = pointerTarinaiTooltipInfoLocal(this); if (tarinaiInfo?.target) return tarinaiInfo; - const nest = this.pointerNestBoxInfo?.(); - if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: nest.box?.type === "pipe" ? "pipe" : "nest_box" }; - const bedInfo = pointerOwnedBedInfoLocal(this); - if (bedInfo?.bed) return { target: bedInfo.bed, lines: ownedBedTooltipLinesLocal(this, bedInfo.bed), kind: "grass_bed" }; return null; }, startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; + if (a.juvenile || b.juvenile) return false; if (!(this.canAddTarinai?.(1) ?? true)) return false; if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false; if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false; diff --git a/js/world_pathfinding_system.js b/js/world_pathfinding_system.js index c39b4c8..6043280 100644 --- a/js/world_pathfinding_system.js +++ b/js/world_pathfinding_system.js @@ -145,8 +145,17 @@ }; let blockers = 0; - for (const rect of rects) { - if (!this.isFenceRoutingRect?.(rect)) continue; + const seenNestBoxes = new Set(); + for (const rawRect of rects) { + if (!this.isFenceRoutingRect?.(rawRect)) continue; + let rect = rawRect; + const routingItem = rawRect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + rect = this.nestBoxBaseRect?.(routingItem) || rawRect; + } const inflated = rect?.oriented ? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding } : { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding }; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 1eddc18..2a59cd5 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -100,7 +100,6 @@ const commit = (value) => { const next = sanitizeSignboardText(value); sign.text = next; - worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe"); worldRef.drawListDirty = true; global.render?.(); close(); @@ -210,7 +209,6 @@ robot.robotCleanerScanTimer = 0; worldRef?.markItemBucketsDirty?.("robot-cleaner-settings"); if (worldRef) worldRef.drawListDirty = true; - worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot, source: "robot-cleaner-settings" }); if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002"); global.render?.(); @@ -308,7 +306,6 @@ item.angle = item.fanSwingCenter; worldRef?.markSpatialDirty?.("fan-swing-settings"); if (worldRef) worldRef.drawListDirty = true; - worldRef?.log?.(item.fanSwingOn ? "\u6247\u98a8\u6a5f\u306e\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u6247\u98a8\u6a5f\u3092\u56fa\u5b9a\u5411\u304d\u306b\u3057\u305f\u3002", "observe"); if (typeof showToast === "function") showToast(item.fanSwingOn ? "\u6247\u98a8\u6a5f: \u9996\u632f\u308aON" : "\u6247\u98a8\u6a5f: \u56fa\u5b9a\u5411\u304d"); global.render?.(); close(); @@ -444,7 +441,6 @@ global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap"); worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited"); worldRef.drawListDirty = true; - worldRef?.log?.(isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u56de\u8ee2\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.render?.(); close(); }; @@ -588,7 +584,6 @@ global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, "reciprocator-link-remap"); worldRef?.markSpatialDirty?.("reciprocator-edited"); worldRef.drawListDirty = true; - worldRef?.log?.("\u5f80\u5fa9\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.render?.(); close(); }; @@ -1062,7 +1057,6 @@ item.gateOpen = !item.gateOpen; worldRef?.markSpatialDirty?.("gate-toggle"); worldRef.drawListDirty = true; - worldRef?.log?.(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5\u3092\u958b\u3051\u305f\u3002" : "\u30b2\u30fc\u30c8\u67f5\u3092\u9589\u3081\u305f\u3002", "observe"); if (typeof showToast === "function") showToast(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5: \u958b" : "\u30b2\u30fc\u30c8\u67f5: \u9589"); global.render?.(); return true; @@ -1170,6 +1164,9 @@ const search = placementSoftSearchRadius(item); for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) { if (!soft || soft === item || soft.dead || !isSoftPlacementIgnoredItemType(soft.type)) continue; + // Easy beds are intentionally valid under a parasol. Placing the parasol + // after the bed must preserve the same layout that AI can build later. + if (item.type === "rain_shelter" && soft.type === "grass_bed") continue; if (placementItemOverlapsSoftItem(worldRef, item, soft)) out.push(soft); } return out; @@ -1240,7 +1237,6 @@ } this.copyBuffer = makeCopyBufferForItem(target); showToast(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002`); - this.log?.(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u305f\u3002`, "observe"); return true; } const item = this.copyPreviewItemAt?.(x, y); @@ -1251,7 +1247,6 @@ if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste"); this._forceImmediateToolVisualRefresh?.("tool-change"); const label = toolLabel(item.type); - this.log?.(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u305f\u3002`, "observe"); showToast(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3057\u305f\u3002`); return true; } @@ -1540,10 +1535,12 @@ if (!found && !this.findDeleteToolTargetAt?.(x, y)) { global.TarinaiAchievements?.recordEmptyClick?.({ world: this, x, y }); } - const wasSelectionEmpty = !this.selected; - if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true; - else this._scrollSelectedDataTopOnNextSelection = false; - global.TarinaiCommands.setSelection(this, found) || (this.selected = found); + this._scrollSelectedDataTopOnNextSelection = Boolean(found); + const selectionResult = global.TarinaiCommands.setSelection(this, found); + if (!selectionResult) this.selected = found; + if (found && selectionResult?.changed === false) { + global.setTimeout?.(() => global.scrollToSelectedDataPanel?.(), 0); + } return; } @@ -1564,7 +1561,7 @@ const vx = dx / length * speed; const vy = Math.min(dy / length * speed - 95, -145); if (target.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) { - this.effects?.push(new Effect("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" })); + this.queueEffect?.("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }); this.drawListDirty = true; audio.poke?.(); global.TarinaiAchievements?.recordPlushiePokeFling?.({ world: this, item: target, owner }); @@ -1579,16 +1576,14 @@ if (global.TarinaiMechanicalSystem.applyPokeImpulse(target, x, y, this)) { const label = target.type === "rotator" ? "\u56de\u8ee2\u4f53" : (target.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53"); const color = target.type === "rotator" ? "rgba(184,132,230,0.40)" : (target.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)"); - this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color })); + this.queueEffect?.("ring", x, y, { size: 22, life: 0.18, color }); this.drawListDirty = true; audio.poke?.(); - this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" }); } return; } if (typeof target.poke === "function") { target.poke(); - if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); } return; } @@ -1599,7 +1594,6 @@ if (!t) { showToast(`\u305f\u308a\u306a\u3044\u500b\u4f53\u6570\u306f${this.tarinaiPopulationLimit}\u5339\u307e\u3067\u3067\u3059\u3002`); return; } t.wanderAngle = spot.angle; global.TarinaiAchievements?.recordManualTarinaiAdded?.({ world: this, tarinai: t, tool: "new" }); - this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); return; } diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index 316d888..bef484d 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -108,6 +108,14 @@ fps: window.__tarinaiFps || 0, dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0, }, + runtimeCaches: { + ballCollisions: this.ballCollisionMemo?.size || 0, + tarinaiCollisions: this.tarinaiCollisionMemo?.size || 0, + relationNotices: Object.keys(this.relationNotices || {}).length, + fightCooldowns: Object.keys(this.fightPairCooldowns || {}).length, + resolvedFights: Object.keys(this.resolvedFightIds || {}).length, + familyRecords: Object.keys(this.family || {}).length, + }, work: { budget: this.workBudget || null, stats: this.workStats || null, diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index c029fde..02910a6 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -45,8 +45,10 @@ global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "delete", x, y }); const target = this.findDeleteToolTargetAt?.(x, y); if (!target) { showToast("\u6d88\u305b\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return true; } - if (isPinType(target.type) && target.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3001\u3064\u307e\u3093\u3067\u629c\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return true; } const label = toolLabel(target.type); + if (isPinType(target.type) && target.pinState === "lodged") { + global.TarinaiItemDynamicPinSystem?.detach?.(target, this, "pinch"); + } const removed = global.TarinaiStructureLifecycle.deleteItem(this, target, { reason: "delete-tool", userReason: target.type === "grass_bed" ? "\u30d9\u30c3\u30c9\u304c\u306a\u304f\u306a\u3063\u305f" : "\u7f6e\u304d\u3082\u306e\u304c\u306a\u304f\u306a\u3063\u305f", @@ -58,7 +60,6 @@ this.compactItems?.(); this.updateItemCounts?.(); this.rebuildSpatial?.(); - this.log?.(`${label}\u3092\u6d88\u3057\u305f\u3002`, "observe"); return true; }, @@ -116,7 +117,6 @@ if (options.drag) this._areaDeleteDragRemoved = (this._areaDeleteDragRemoved || 0) + removedCount; if (!options.silentLog) { const detail = [...kindCounts.entries()].slice(0, 4).map(([label, count]) => `${label}${count > 1 ? `\u00d7${count}` : ""}`).join("\u3001"); - this.log?.(`\u7bc4\u56f2\u524a\u9664\u3067${removedCount}\u500b\u6d88\u3057\u305f${detail ? `\uff08${detail}\uff09` : ""}\u3002`, "observe"); showToast(`${removedCount}\u500b\u6d88\u3057\u307e\u3057\u305f\u3002`); } return true; @@ -256,7 +256,6 @@ global.TarinaiAchievements?.recordLinkPlaced?.(constraintType, { world: this, item, type: constraintType }); global.TarinaiPhysicsSoftClear.clearForItem(this, item, `${constraintType}-soft-overlap-clear`); this._forceImmediateToolVisualRefresh?.("tool-action"); - this.log?.(`${toolName}\u3092\u3064\u306a\u3044\u3060\u3002`, "observe"); showToast(`${toolName}\u3092\u3064\u306a\u304e\u307e\u3057\u305f\u3002`); return true; }, @@ -405,8 +404,8 @@ global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "shoot", x: aim.x, y: aim.y }); const hit = this.findShootToolTargetAt?.(aim.x, aim.y); audio.shoot?.(); - this.effects?.push(new Effect("shoot_impact", aim.x, aim.y, { size: hit ? 22 : 16, life: hit ? 0.28 : 0.22, color: hit ? "rgba(255,208,116,0.96)" : "rgba(190,212,240,0.64)" })); - this.effects?.push(new Effect("ring", aim.x, aim.y, { size: hit ? 20 : 14, life: hit ? 0.18 : 0.14, color: hit ? "rgba(255,246,206,0.74)" : "rgba(196,220,255,0.42)" })); + this.queueEffect?.("shoot_impact", aim.x, aim.y, { size: hit ? 22 : 16, life: hit ? 0.28 : 0.22, color: hit ? "rgba(255,208,116,0.96)" : "rgba(190,212,240,0.64)" }); + this.queueEffect?.("ring", aim.x, aim.y, { size: hit ? 20 : 14, life: hit ? 0.18 : 0.14, color: hit ? "rgba(255,246,206,0.74)" : "rgba(196,220,255,0.42)" }); let handled = false; if (hit?.kind === "tarinai") { const t = hit.target; @@ -453,7 +452,7 @@ target.noStickUntil = (this.time || 0) + 0.22; target.lastPokedAt = this.time || 0; } - this.effects?.push(new Effect("ring", target.x || aim.x, target.y || aim.y, { size: Math.max(12, (target.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.48)" })); + this.queueEffect?.("ring", target.x || aim.x, target.y || aim.y, { size: Math.max(12, (target.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.48)" }); this.markSpatialDirty?.("shoot-pin-blast"); this.drawListDirty = true; } else { diff --git a/js/world_view.js b/js/world_view.js index 8ed9b85..2f41c35 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -196,7 +196,7 @@ if (next !== previous && !opts.silent) { window.TarinaiAchievements?.recordGroundChange?.({ world: this, previous, next, silent: false }); } - window.TarinaiWeatherSystem.enforceFixedWeather(this); + window.TarinaiWeatherSystem.enforceFixedWeather(this, { silentLog: true }); this.markTerrainDirty?.("ground-type"); this.enforceGrassLimit?.("ground-type"); this.updateItemCounts?.("ground-type"); @@ -211,7 +211,6 @@ } if (!opts.silent) { const label = this.groundLabel?.() || "\u5730\u9762"; - this.log?.(`\u5730\u9762: ${label}`); showToast(`\u5730\u9762\u3092 ${label} \u306b\u3057\u307e\u3057\u305f\u3002`); } } diff --git a/scripts/achievement_audio_tooltip_regression_audit.js b/scripts/achievement_audio_tooltip_regression_audit.js new file mode 100644 index 0000000..8ae8dd2 --- /dev/null +++ b/scripts/achievement_audio_tooltip_regression_audit.js @@ -0,0 +1,22 @@ +"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); diff --git a/scripts/achievement_audit.js b/scripts/achievement_audit.js index 8ab5aa3..50b25a5 100644 --- a/scripts/achievement_audit.js +++ b/scripts/achievement_audit.js @@ -115,13 +115,13 @@ const expectedIds = [ "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", + "well_informed", "lively_making", "memento_mori", "mad_scientist", ]; (async function run() { let h = createHarness(); let storage; - assert(h.api.definitions.length === 75, "definition count must be 75"); + assert(h.api.definitions.length === 76, "definition count must be 76"); { const spellHarness = createHarness(); spellHarness.api.unlock("statistician"); @@ -131,6 +131,9 @@ const expectedIds = [ 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"); @@ -141,7 +144,7 @@ const expectedIds = [ 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"); + 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"); @@ -230,14 +233,14 @@ const expectedIds = [ 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"); + 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 繁殖・人口"); + 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); @@ -698,6 +701,13 @@ const expectedIds = [ 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 }); @@ -707,7 +717,7 @@ const expectedIds = [ 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 75 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed"); + console.log("[OK] all 76 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed"); })().catch(error => { console.error(error); process.exitCode = 1; diff --git a/scripts/achievement_integration_audit.py b/scripts/achievement_integration_audit.py index ae33782..9368f61 100644 --- a/scripts/achievement_integration_audit.py +++ b/scripts/achievement_integration_audit.py @@ -17,14 +17,14 @@ 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) == 75, 'JS achievement count is not 75') +require(len(ids_js) == 76, 'JS achievement count is not 76') 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']), 'latest 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 = { diff --git a/scripts/effect_runtime_regression_audit.js b/scripts/effect_runtime_regression_audit.js index 472ca87..9b73fd8 100644 --- a/scripts/effect_runtime_regression_audit.js +++ b/scripts/effect_runtime_regression_audit.js @@ -6,10 +6,13 @@ 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('new Set(["ring", "fight", "flame", "fall"])'), "effect pool is limited to simple high-frequency types"); +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\(new Effect\(/g)].length; -ok(direct === 0, "normal Effect construction is routed through spawnEffect"); +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"); diff --git a/scripts/long_session_runtime_regression_audit.js b/scripts/long_session_runtime_regression_audit.js new file mode 100644 index 0000000..ec474c6 --- /dev/null +++ b/scripts/long_session_runtime_regression_audit.js @@ -0,0 +1,84 @@ +"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."); diff --git a/scripts/object_render_cursor_regression_audit.js b/scripts/object_render_cursor_regression_audit.js new file mode 100644 index 0000000..7ac9de7 --- /dev/null +++ b/scripts/object_render_cursor_regression_audit.js @@ -0,0 +1,25 @@ +"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."); diff --git a/scripts/offscreen_motion_performance_regression_audit.js b/scripts/offscreen_motion_performance_regression_audit.js new file mode 100644 index 0000000..45204b7 --- /dev/null +++ b/scripts/offscreen_motion_performance_regression_audit.js @@ -0,0 +1,16 @@ +"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, cadence\.near \? 0\.45 : 0\.75\)/.test(creature), "off-screen scheduled updates catch up bounded elapsed movement time"); +ok(/context\.offscreenCatchup \? 0\.75 : 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."); diff --git a/scripts/playstyle_achievement_audit.js b/scripts/playstyle_achievement_audit.js index 6cb8648..aacc24c 100644 --- a/scripts/playstyle_achievement_audit.js +++ b/scripts/playstyle_achievement_audit.js @@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st (function run(){ let h=harness(); - assert(h.api.definitions.length===75,"must have 75 achievements"); + 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"); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index a557696..498cd9b 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -306,6 +306,7 @@ const api = context.TarinaiDirectFeedingSystem; if (!api || !api.isDirectFeedType('sweet') || !api.isDirectFeedType('protein') || api.isDirectFeedType('ball')) throw new Error('direct-feed eligibility mismatch'); const world = {{ tarinai: [], items: [], effects: [], logs: [], time: 12, hoverGiveTarget: null, + queueEffect(type, x, y, options = {{}}) {{ const effect = new TestEffect(type, x, y, options); this.effects.push(effect); return effect; }}, isTarinaiHiddenInNestBox() {{ return false; }}, log(text, kind, meta) {{ this.logs.push({{ text, kind, meta }}); }}, recordFamily(t) {{ this.recorded = t; }}, @@ -338,7 +339,7 @@ if (top.currentPersonality.openness <= 0.006) throw new Error('food gift does no const expectedPersonalityReason = '\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f'; if (!top.personalityReasons?.length || top.personalityReasons.some(reason => reason !== expectedPersonalityReason)) throw new Error('direct-feed personality reason mismatch'); if (top.lastBubble !== '\u2661' || context.uiCache.selectedSnapshot !== '') throw new Error('food gift feedback/cache refresh missing'); -if (!world.logs.some(entry => entry.meta?.eventType === 'direct_feed')) throw new Error('direct-feed observation log missing'); +if (world.logs.some(entry => entry.meta?.eventType === 'direct_feed')) throw new Error('direct-feed player operation must not enter the event log'); const priorEffects = world.effects.length; const medicine = {{ type: 'protein', amount: 5, foodServingsRemaining: 5, x: 20, y: 20 }}; world.items.push(medicine); @@ -520,7 +521,9 @@ def check_social_balance_guards() -> None: "approach_mate: 72", "approach_friend: 66", "return ({ conflict: 18, mate: 24, family: 12, bond: 14", - "const relationDrive = Math.max(Number(parts.mate || 0)", + 'if (subNeed === "mate")', + "if (subValue >= threshold) return true", + "const relationDrive = Math.max(Number(parts.conflict || 0)", "drugBoosted || relationDrive >= Math.min", "social-fight-vs-mate-weighted", ] @@ -1197,8 +1200,12 @@ def check_tarinai_update_pipeline_boundary() -> None: fail("Tarinai need planner core system export missing") if "TarinaiItemInteractionSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_ai.js"): fail("Tarinai AI step does not delegate item interactions to subsystem") - if "TarinaiCursorCareSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_frame.js"): - fail("Tarinai frame step does not delegate cursor care to subsystem") + cursor_care_frame = read_text(ROOT / "js/tarinai_update_step_frame.js") + cursor_care_sim = read_text(ROOT / "js/simulation_creature_system.js") + if "TarinaiCursorCareSystem.updateOne" not in cursor_care_sim: + fail("Creature simulation does not delegate cursor care to subsystem") + if "TarinaiCursorCareSystem.updateOne" in cursor_care_frame: + fail("Cursor care is still duplicated inside the cadence-throttled Tarinai frame step") if "TarinaiLocalEnvironmentSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_environment.js"): fail("Tarinai environment step does not delegate local environment to subsystem") if "TarinaiSunbathSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_movement.js"): diff --git a/scripts/reproduction_memory_sprite_regression_audit.js b/scripts/reproduction_memory_sprite_regression_audit.js new file mode 100644 index 0000000..2ba7cd3 --- /dev/null +++ b/scripts/reproduction_memory_sprite_regression_audit.js @@ -0,0 +1,26 @@ +"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*30\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."); diff --git a/scripts/reproduction_regression_audit.js b/scripts/reproduction_regression_audit.js new file mode 100644 index 0000000..30a8d44 --- /dev/null +++ b/scripts/reproduction_regression_audit.js @@ -0,0 +1,27 @@ +"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."); diff --git a/scripts/secondary_achievement_regression_audit.js b/scripts/secondary_achievement_regression_audit.js index d4f0a77..e29eeda 100644 --- a/scripts/secondary_achievement_regression_audit.js +++ b/scripts/secondary_achievement_regression_audit.js @@ -88,6 +88,19 @@ function assert(value, message) { if (!value) throw new Error(message); } 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"); @@ -102,5 +115,5 @@ function assert(value, message) { if (!value) throw new Error(message); } 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, minimalist save metadata, and lethal wire shocks passed"); + console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions including manual Tarinai addition, minimalist save metadata, and lethal wire shocks passed"); })(); diff --git a/scripts/social_interaction_regression_audit.js b/scripts/social_interaction_regression_audit.js index eda29a3..db19dfa 100644 --- a/scripts/social_interaction_regression_audit.js +++ b/scripts/social_interaction_regression_audit.js @@ -10,8 +10,9 @@ ok(!needSrc.includes('const crowdSample = tarinai.socialCrowdSample'), "relation 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)'), "contact candidates are no longer truncated by spatial bucket order"); -ok(!moveSrc.includes('if (inspected >= candidateBudget) break'), "contact scans do not stop before considering nearer later candidates"); +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"); diff --git a/scripts/tooltip_nail_colony_focus_regression_audit.js b/scripts/tooltip_nail_colony_focus_regression_audit.js new file mode 100644 index 0000000..0af0d13 --- /dev/null +++ b/scripts/tooltip_nail_colony_focus_regression_audit.js @@ -0,0 +1,23 @@ +"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); diff --git a/scripts/ui_grass_limits_regression_audit.py b/scripts/ui_grass_limits_regression_audit.py index 56afb03..ef947c3 100644 --- a/scripts/ui_grass_limits_regression_audit.py +++ b/scripts/ui_grass_limits_regression_audit.py @@ -75,7 +75,7 @@ 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: "\u7e41\u6b96\u30fb\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization 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') diff --git a/service-worker.js b/service-worker.js index 25719a8..592af65 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.16.98"; +const APP_VERSION = "39.16.109"; 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. From f6d7412744b67b0413b091d9277090d465b78cf4 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 20 Jul 2026 14:36:59 +0900 Subject: [PATCH 2/4] a --- AI_MAP.md | 29 +- BUILD_NOTE.txt | 283 +------- FILES.json | 2 +- GAME_FEATURES.md | 21 +- README.md | 53 +- achievement_api.php | 19 +- app_manifest.json | 4 +- assets/sounds/bicycle_bell.mp3 | Bin 0 -> 30092 bytes css/base.css | 5 + css/components.css | 16 +- css/layout.css | 6 + css/panel.css | 46 +- index.html | 372 ++++++----- js/achievements.js | 94 ++- js/assets.js | 2 +- js/audio.js | 1 + js/circuit_board_system.js | 6 +- js/collision_footprint_system.js | 2 +- js/command_dispatcher.js | 12 +- js/data.js | 2 +- js/deterministic_helpers.js | 1 - js/health.js | 4 + js/impact_response_system.js | 8 + js/item_dynamic_ball_system.js | 5 +- js/item_dynamic_system.js | 6 + js/item_dynamic_zunchi_system.js | 3 + js/item_environment_hazard_system.js | 382 +++++++++++ js/item_render_runtime.js | 529 +++++++++++++-- js/item_tool_definitions.js | 5 + js/item_tool_metadata.js | 8 +- js/item_type_initializers.js | 8 +- js/item_update_policy.js | 17 +- js/item_update_scheduler.js | 22 +- js/main.js | 12 + js/mechanical_system.js | 106 ++- js/memorial_bell_system.js | 629 ++++++++++++++++++ js/perf_profiler.js | 61 +- js/physics_helpers.js | 6 +- js/placement_preview_system.js | 4 +- js/render.js | 321 +++++++-- js/robot_cleaner_system.js | 130 +++- js/save_schema.js | 2 +- js/seesaw_system.js | 2 +- js/signal_system.js | 133 +++- js/sim_core.js | 89 +-- js/simulation_creature_system.js | 11 +- js/simulation_effects_system.js | 30 +- js/simulation_maintenance_system.js | 3 + js/simulation_runtime_helpers.js | 6 +- js/snapshot_system.js | 69 +- js/sound_pack.js | 4 +- js/structure_lifecycle.js | 2 + js/structures.js | 104 +++ js/system_order.js | 43 +- js/tarinai.js | 1 + js/tarinai_behavior_text.js | 3 +- js/tarinai_consumable_behavior.js | 1 + js/tarinai_contact_item_system.js | 24 + js/tarinai_direct_feeding_system.js | 1 + js/tarinai_disease_nest.js | 26 +- js/tarinai_food_interaction_system.js | 1 + js/tarinai_forced_behavior.js | 3 + js/tarinai_item_interaction_system.js | 1 + js/tarinai_item_targeting.js | 3 + js/tarinai_needs_items.js | 7 +- js/tarinai_nest_sleep_system.js | 53 +- js/tarinai_render.js | 21 +- js/tarinai_social_move_life.js | 12 +- js/tarinai_update_step_ai.js | 4 + js/tarinai_update_step_movement.js | 2 +- js/text_catalog.js | 4 + js/ui.js | 8 +- js/ui_bind.js | 76 ++- js/ui_charts.js | 396 +++++++---- js/ui_family_layout.js | 33 +- js/ui_family_render.js | 16 +- js/ui_layout_dialogs.js | 21 +- js/ui_log.js | 17 +- js/ui_selected.js | 36 +- js/ui_tools.js | 5 +- js/version.js | 4 +- js/world.js | 10 + js/world_combat_effects.js | 116 +++- js/world_family_social.js | 122 +++- js/world_placement_log.js | 14 +- js/world_spatial_budget.js | 162 ++++- js/world_view.js | 21 +- scripts/achievement_audit.js | 37 +- scripts/achievement_integration_audit.py | 8 +- scripts/achievement_save_v8_audit.js | 47 ++ ...een_motion_performance_regression_audit.js | 4 +- scripts/regression_check.py | 2 +- ...oduction_memory_sprite_regression_audit.js | 2 +- service-worker.js | 4 +- 94 files changed, 3839 insertions(+), 1229 deletions(-) create mode 100644 assets/sounds/bicycle_bell.mp3 create mode 100644 js/item_environment_hazard_system.js create mode 100644 js/memorial_bell_system.js create mode 100644 scripts/achievement_save_v8_audit.js diff --git a/AI_MAP.md b/AI_MAP.md index 820acb1..db0401f 100644 --- a/AI_MAP.md +++ b/AI_MAP.md @@ -6,7 +6,6 @@ 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.78`. - 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 +83,6 @@ 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.78`. - 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. @@ -92,14 +90,23 @@ This section records current boundaries only. Version-by-version cleanup history - 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 - resolved +## Cleanup Candidate Audit - rechecked 2026-07-18 -The previous cleanup candidate queue has been applied or adjusted. Current state: +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. -- Version/docs routing was aligned to `39.16.78`; `js/version.js` build identity now matches the app version, and `README.md` no longer points at an older cache-query script URL. -- One-version and overlapping audit scripts from the candidate list were removed; the broad audit suite remains `scripts/achievement_audit.js`, `scripts/playstyle_achievement_audit.js`, `scripts/achievement_integration_audit.py`, `scripts/achievement_server_state_audit.py`, and `scripts/regression_check.py`. -- Shared scalar/object/clone/hash/text helpers now live on `TarinaiCoreHelpers` in `js/deterministic_helpers.js`; action, behavior, structure, snapshot, save, geometry, physics, seesaw, placement-preview, circuit, and signal code route repeated helper logic through that shared surface or through `TarinaiGeometry`. -- UI HTML escaping is owned by `js/ui_helpers.js`; consumers bind explicitly to `TarinaiUIHelpers.htmlEscape`, and `index.html` loads `ui_helpers.js` before the consumers that need it. -- `js/achievements.js` uses `renderIfDialogOpen()` for progress refreshes instead of repeating the hidden-dialog check at each event recorder. -- `js/world_placement_log.js` uses shared close/backdrop binding for editor dialogs; deeper editor extraction can still be done as feature work, but the repeated close process from the cleanup list is removed. -- `FILES.json` now includes JavaScript audit scripts and newer core bootstrap files (`domain_ids`, deterministic helpers, item registries/definitions) in machine routing. +### 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. diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index a5d7199..06df610 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -1,282 +1 @@ -tarinai_compact_save_stage4 -- Save schema 33 omits standard item amounts and stores only an optional non-standard block base plus per-item differences. -- Relationship targets are sorted and delta-encoded within each Tarinai row. -- World seeds use a canonical 64-bit hexadecimal identity and occupy exactly 8 bytes in the binary save. -- The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted. -- 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 51 is the current save format; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and persistent statistician progress while v5 remains importable. -- Version: 39.16.109 -- 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. - -- Passive spring: removed time-driven oscillation; force now depends on extension/compression and relative endpoint velocity. -- Signal effects use the exported TarinaiEffect constructor. -- Pressure-switch placement/editor overlays display the configured detection radius. - -- v39.16.105: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths. - -- Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect. - -- v39.16.105: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper. - -- v39.16.105: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic. - -- v39.16.105: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced. -- v39.16.105: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction. - -- v39.16.105: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution. - -- v39.16.105: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks. - -- v39.16.105: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source. - -- v39.16.105: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state. - -- v39.16.105: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner. - -- v39.16.105: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label. -- v39.16.105: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included. - -- v39.16.105: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C. - -- v39.16.105: aligned parasol placement overlay with its shade, made toilet sand absorb rain water, expanded Happy/Athletic/War presets, simplified Athletic rotators, and reserved their full rotation envelope from fences. - -- v39.16.105: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup. - -- v39.16.105: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes. - -- v39.16.105: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops. - -- v39.16.105: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips. - -- v39.16.105: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised. - -- v39.16.105: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons. -- v39.16.105: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background. - -39.16.105: Added local achievements and optional self-hosted shared unlock percentage API. - -39.16.105: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking. - -39.16.105: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements. - -39.16.105: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing. - -39.16.105: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress. - -39.16.105: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field. - -39.16.105: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button. - -39.16.105: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics. - -39.16.105: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss. - -39.16.105: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution. - -39.16.105: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog. - - -39.16.105: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left. - -39.16.105: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync. - -39.16.105: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration. -39.16.105: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards. - -39.16.105: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server. - -39.16.105: Replaced JSON-string achievement metadata in spell/save binaries with a sparse binary schema; achievement unlock masks are raw bits, progress is bit-packed, timestamps use minute deltas, and old v39.16.105 spells remain readable. -39.16.105: Added persistent sniper, megalopolis, and love-mochi fertility achievements; changed chaos progress to fight-mochi-influenced fights only; extended compact spell progress without migrating obsolete generic fight counts; omitted completed achievement counters from spell metadata; and tightened population-limit labels/layout. - -39.16.105: Expanded achievements from 54 to 59 with single-digit FPS, 24-hour continuous play, five toilet-sand placements, park-ground editing, and four ground changes per second; revised safety, observation, and empty-click conditions; moved the two observation achievements to the bottom of その他; and expanded binary achievement masks to eight bytes while retaining schemas 41–43. - -39.16.105: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4. - -## v39.16.105 -- Added 毎日たりない観察 and 抵抗器じゃない. -- Daily streak state is carried by achievement payload v5 and binary schema 46. -- Achievement aggregate JSON migrates compact v2/verbose input to compact v3 automatically under the API file lock. - -## v39.16.105 -- Re-audited the not-yet-deployed shared achievement server with generated randomized unlock data instead of a fixed production sample. -- Shared summaries are read-only; only POST session/unlock/sync/reset calls register or update players, and GET mutations are rejected. -- State writes now use a separate lock file plus atomic same-directory replacement, preserving the previous state across interrupted writes. -- Invalid or structurally unknown JSON returns `storage_corrupt` without overwriting the source file. -- `completionistEligible: false` now prevents `true_tarinai_observer` from being re-added by the same sync payload. -- The randomized server audit covers 32 v2/verbose migrations and 72 concurrent player syncs with exact semantic comparison. - -## v39.16.105 -- Added seesaw cooperative play and friendship growth. -- Added circuit-board editor with positioned input/output terminals and AND/OR/NOT gates. -- Added electrical control for fan, stove, dry ice, and duplicator. -- Added connectable-item and circuit-terminal highlights while wiring. -- Save snapshot/schema: 38/47. - -## v39.16.105 -- Circuit board editor now uses a grid canvas with direct gate placement and cursor-drawn wiring. -- Legacy circuit layouts are intentionally not converted. -- Colony state evaluates every two in-game hours; growing populations cannot be classified as Weakened. -- Seesaw action selection, fixed orientation, and placement preview were corrected. - -## v39.16.105 -- Seesaw leisure can start at a lower boredom threshold, and a waiting rider raises the second rider's action priority. -- Active paired play is shown as 「シーソーをしている。」 in selected Tarinai data. -- Seesaws render in the low/back item layer instead of covering Tarinai sprites. -- AND/OR gate input pins and gate outputs are snapped to the same 40px circuit-editor grid used by cursor wiring. - -## v39.16.105 -- Circuit-board internal wiring now uses straight grid segments with explicit branch junctions and unlimited fan-out per terminal. -- Removed OR and added a one-input/one-output rectifier; internal logic parts rotate in 90-degree steps. -- Circuit-board external terminal counts and positions are editable; the board footprint and default terminal spacing were expanded. -- Detector numeric conditions now use a dual-thumb minimum/maximum range plus direct numeric inputs. -- Seesaw riders render slightly above the seat surface. -- Save snapshot/schema: 39/48. Older saves are intentionally unsupported. - - -## v39.16.105 -- The default circuit-board grid is now 10x6, one quarter of the previous editor area, with a correspondingly smaller world footprint. -- Circuit-board width and height are editable; the board world size follows the grid dimensions. -- Input/output terminal count is limited by board height, and every external terminal can be dragged independently onto the left, right, top, or bottom edge. -- Branch junctions can be moved directly; all connected straight segments follow the junction. -- NOT and rectifier bodies are visually smaller while their pins remain on grid intersections. -- Existing external wires follow terminal positions after a board resize or terminal move. -- Seesaw action reasons now use play-specific wording instead of inheriting unrelated fulfillment reasons such as bed demand. -- Save snapshot/schema: 40/49. Older saves are intentionally unsupported. - - -## v39.16.105 -- Circuit-board external terminals are now placed directly with IN/OUT terminal tools; the +/- terminal-count controls were removed. -- Disconnected external terminals are dimmed in the circuit-board editor; connected terminals retain their signal colors and cannot be deleted until their world wire is removed. -- Placement previews no longer use the generic ellipse fallback: circle items use circles, obstacle/mechanical items use their collision footprints, and special items use dedicated shapes. -- Detector, wire, insulated wire, and circuit board moved from the Physics palette section to a new Electrical section. -- Clicking a detector dual-range track outside either thumb teleports the nearer thumb to the clicked value. -- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. - - -## v39.16.105 -- Circuit-board default grid is 10x10 and both dimensions can grow to 20x20; the world footprint scales proportionally in both axes. -- AND gate body height is two grid cells. Compact one-input parts can be placed one grid row from the top/bottom edges when their rotated pins remain in bounds. -- Placement previews prefer shared occupancy/collision footprints; the dedicated seesaw preview was removed and circuit boards use their rectangular board footprint. -- Internal combinational feedback loops are explicitly detected and forced OFF; a NOT self-loop is unstable and does not provide power. -- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. - - -## v39.16.105 -- Circuit-board editing shows placement ghosts for pending internal parts and terminals. -- Q/E rotates pending internal parts by 90 degrees before placement. -- The ON constant source is documented in the editor. -- Wire and insulated-wire palette silhouettes render correctly in the Electrical category. -- Save snapshot/schema remains 40/49. - - -## v39.16.105 -- Removed the circuit-board OFF constant source. -- 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.105 -- 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. -- Fixed ミニマリスト placement-origin metadata so it remains saveable after 無計画都市 has already unlocked. -- Fixed サウナ水風呂 to measure 15 seconds from genuine entry into a hot state instead of refreshing the hot timestamp on every hot evaluation. -- Fixed 抵抗器じゃない so a Tarinai killed by the qualifying shock still counts before death clears its live state. -- Achievement reset now clears reset-scoped hidden per-Tarinai and per-item progress, including care counts, sauna state, held duration, fight-mochi metadata, sticky-bomb pass count, and quick-delete timestamps. -- Undo/Redo and valid ground changes now reset 監視カメラ observation progress as world interventions. -- Added route-level and secondary regression coverage for these achievement paths. Ambiguous achievement semantics identified by the renewed audit remain unchanged pending explicit specification. - -39.16.105: Applied confirmed achievement semantics: birth-status slave/king appearances count; dynasty achievements require direct parent-child qualifying lineage; Overprotective treatment counts only actual beneficial health recovery; Unplanned City counts deletion operations rather than removed objects; Surveillance Camera requires 3 uninterrupted game days and resets on gameplay interventions; wire-shock partial progress remains nonpersistent; Pest Control counts only player-caused ant deaths. - - -## v39.16.105 -- Optimized Tarinai social-neighbor scans with squared-distance filtering, nearest-N partial selection, pair-deduplicated symmetric work, cached personality profiles, and shared disease spread logic. -- Added personality-derived crowd comfort bounds. Overcrowded Tarinai flee toward less crowded space with the specified behavior text; undercrowded Tarinai become stressed and approach the nearest living Tarinai with the specified loneliness text. - - -## v39.16.105 -- Replaced Undo/Redo object snapshots and recursive patch history with the existing synchronous binary save codec payload. -- Exposed binary snapshot encode/decode helpers from TarinaiSaveCodec for internal history reuse without Base32768 text conversion or asynchronous compression. -- Replaced JSON.stringify-based duplicate detection with a compact dual 32-bit fingerprint over the encoded history bytes. -- Unified Undo and Redo stack movement through a shared history transition path while preserving synchronous behavior and the 48-entry limit. -- Added binary-history regression coverage for storage shape, duplicate suppression, Undo restoration, and Redo restoration. - - -## v39.16.105 -- Render stack optimization: static visible stacks are cached by static spatial generation and camera region, while dynamic stacks reuse previous-frame order before insertion sorting and are merged linearly. -- Link rendering now uses dedicated AABB spatial cells instead of per-frame full type-bucket fallback scans. -- Carried plushies render at owner-relative positions without mutating simulation coordinates during rendering. -- Fixed render culling radii are cached on eligible static entities. -- Render-layer sort keys are computed once per stack insertion. - - -## v39.16.105 -- Reduced fixed render cost by skipping the fallback full-screen gradient whenever the transformed field fully covers the viewport. -- Cached full-screen fallback gradients and rebuilt atmospheric full-screen lighting at 8 Hz instead of constructing multiple full-screen gradients every frame. -- Reused dynamic render-layer entries, sort maps, and previous-order ID arrays to reduce per-frame garbage collection as population grows. - - -## v39.16.105 -- Removed social crowd-retreat selection and the overcrowded colony-state distance scan. -- Removed the unconditional three-second full terrain-cache invalidation; cached terrain now refreshes only from real terrain changes, with splat fading invalidating local chunks. -- Throttled structure dependency safety sweeps, staggered item/ant compaction, and stopped timer-only item bucket rebuilds. -- Deferred periodic statistics UI refreshes and collapsed population statistics into a single pass. -- Ordinary item additions/removals no longer invalidate the full terrain cache unless cached terrain items actually changed. - -## v39.16.105 -- Large-save restore uses an adaptive non-damaging settling phase for dense loaded populations, preventing synthetic body-overlap and obstacle-correction impulses from cascading into impact deaths. -- Duplicator loading takes priority over direct feeding when a Tarinai overlaps the machine. -- Save schema 50 explicitly stores elapsed completed days; total simulation time is restored monotonically and seasons/climate derive from elapsed days. Schema 49 spells remain readable. - - -## v39.16.105 -- Moved the ずんちどれい and たりない王 population-chart toggles to immediately after 死亡数. -- Removed the 12-second periodic Tarinai runtime-cache full sweep that caused large-population performance regressions. -- Runtime relationship/cache cleanup is now scheduled only after accumulated deaths reach max(20, floor(living population * 15%)), then runs once during the next Tarinai compaction and resets its counter. - - -## v39.16.105 -- Added eight playstyle-survey achievements: 数こそ力, 少数精鋭, 踩屎感, 快適な寝床, 石に枕す, 季節を越えて, 推し, and 統計学者. -- 数こそ力 unlocks at 100 living Tarinai. 少数精鋭 requires 1-25 living Tarinai continuously for five game days and discloses its condition/progress before unlock. -- 統計学者 counts 30 statistics-tab or chart-series visibility button presses in persistent achievement state, so field resets do not clear its progress. -- Achievement spell payload v6 and binary save schema 51 expand the unlock mask from 64 to 72 achievements while retaining v5/schema 49-50 read compatibility. -- Updated the Item lifecycle pipeline regression to validate local bucket/spatial invalidation when passive serving-food decay actually changes its visual footprint. - - -## v39.16.105 -- Removed previous-order render Maps and custom insertion sorting; dynamic visible lists now use native Array.sort after culling. -- Removed the shared route cache while retaining actor-local route throttling. -- Disabled detailed performance counters in normal play; diagnostics remain available only in explicit debug/perf mode. -- Isolated obstacle-query cache invalidation to routing-obstacle generations instead of generic spatial movement. -- Replaced per-frame full Tarinai spatial-grid rebuilds with cell-crossing incremental updates. -- Added sustained hysteresis to automatic performance-profile transitions to prevent rapid quality oscillation. -- Death-reference cleanup now executes in bounded per-frame chunks after the existing adaptive death threshold. - -## v39.16.105 -- Fixed dense-population birth regression caused by incremental Tarinai spatial-grid bucket ordering interacting with an early 20-candidate contact cutoff. Contact interactions now consider the complete already-fetched local candidate set while retaining only the nearest bounded interaction list. -- Moved the "季節を越えて" achievement from the ecology category to "その他". -39.16.105: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed. - -- v39.16.105: restricted in-world item tooltips to sleep furniture; delete tool now removes lodged pins after pinch-equivalent detach effects; raised confusion/breeding colony-state thresholds; clicking a Tarinai scrolls the right panel to Tarinai Data. - - -## v39.16.107 -- Long-session runtime caches now expire independently of deaths; recurring fight resolution no longer creates permanent time-bucket keys. -- Visual effects OFF now gates effect creation/update/drawing, including rain, while ON keeps the existing visual budgets unchanged and uses broader effect pooling plus allocation-free update traversal. -- Hidden family trees no longer run full graph normalization/pruning from simulation maintenance; pending cleanup is handled when the family view is opened. - -## v39.16.106 -- Renamed the achievement 「メメント・モリ」 to 「多数の死は統計上の数字でしかない」. -- Replaced push-only log kinds with six shared event categories: 生命 / 危険 / 交流 / 生活 / 環境 / 観察. Category toggles now filter both the event UI and push notifications. -- Prevented family-tree layout work before first use and while the family section is inactive; tab switches reuse family caches, cancel offscreen in-progress layout work, use one activation event, and throttle scroll-driven tab detection to animation frames. -- Manual Tarinai addition now resets 監視カメラ observation progress as a player intervention. - - -## v39.16.109 -- Repaired family-tree activation after lazy-render cancellation and added blank-view recovery rendering. -- Robot cleaners keep obstructed cleanup targets and route around nest boxes/fences instead of permanently excluding nearby zunchi. -- Added the achievement 「マッドサイエンティスト」 for administering mystery drug to 10 distinct Tarinai. +Current build metadata is defined in js/version.js and app_manifest.json. diff --git a/FILES.json b/FILES.json index 80f5ee5..08ffb02 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.109", + "version": "39.16.137", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index 89ef423..0b440d3 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -75,26 +75,19 @@ Manual save and restore use browser storage. Reset returns to field selection an 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 (v39.16.78) -- Sixty-four 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. +## 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 0–100%, 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 direct feedings in one field; resetting the field resets this progress. +- `餌やりじいさん` 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. -- v39.16.56 adds 混沌ヲ希求スル者 and 潔癖症, raises 清掃業者 to 200, starts ミニマリスト from day 6, excludes chain/crown from おくすり手帳全埋め, and keeps shared-rate bars visible after unlock. -- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day. -- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了. +- 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. -- v39.16.78 fixes achievement edge paths found by a renewed 64-condition audit: Minimalist placement metadata survives unrelated unlocks, Sauna/Cold-Plunge uses true hot-entry timing, lethal qualifying wire shocks count before death cleanup, reset clears hidden reset-scoped progress, and Undo/Redo/ground changes interrupt passive observation. Ambiguous semantics are intentionally unchanged pending specification. -### Achievement spell persistence (v39.16.78) +### 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 a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Binary save schema 51 is current; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and statistician progress, while legacy payload v5 remains importable. -- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements. -- v39.16.62: Added five achievements for low FPS, uninterrupted 24-hour play, toilet-sand placement, park-ground editing, and rapid ground changes. 寿命 deaths are excluded from 安全第一 failures, empty-field clicks replace pause-button clicks for 何やってるの?, and the two long observation achievements are last in その他. - -- v39.16.63: Added アリ観察 and 命のバトン; stabilized progress-row typography; continuous-play observation no longer resets on tab changes; corrected sauna wording and enemy-ant streak reset behavior; obsolete spell formats are rejected. +- 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`. diff --git a/README.md b/README.md index ea6631c..c9ab1b4 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,6 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; ## Runtime Shape -- v39.16.37 changes: biological and environmental conditions are unified into the detector; the standalone sensor and relay are removed; temperature and limits are folded; ecology, sound, display settings, and credits are in the header menu; reset remains outside in red; tarinai-data navigation preserves the active tool; mobile tool descriptions appear at the top for five seconds. Snap mode remains Free/20px/40px/60px, top-edge placement is fixed, and preset descriptions end with punctuation. -- v39.16.38 changes: detector numeric controls use value-labelled sliders like the time control; colony temperature and limits share one Colony settings fold; display settings use an inline menu matching sound settings; HP/stress meter colors are fixed; the snap control follows Clear with a faint grid icon; area-delete has a new icon; the Ant chart series is renamed and hidden initially. -- v39.16.55 changes: expanded the achievement set to 49 with 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one happy in-game day from play day 6; restored shared poke targeting/highlights and plushie fling; corrected manual rotator/reciprocator push direction; tightened tool/limit UI; reduced periodic achievement scans; and introduced compact achievement aggregate storage. -- v39.16.52 changes: reorganized achievements into collapsible categories, removed achievement tooltips, and moved confirmation flows into the game UI. Previous v39.16.51 changes: expanded the achievement set to 43 with sixteen play-style and special-action conditions; added persistent Undo/Redo and quick-delete counters, field-local cleaner/automation-free ecology progress, individual care/temperature/fight tracking, complete medicine-category collection, protected rain shelter in nest boxes and pipes, and stricter ant-kill attribution. -- v39.16.49 changes: added additive full-state synchronization so shared achievement aggregates can be rebuilt as players return after server data loss. -- v39.16.48 changes: corrected the zunchi-slave threshold text to 30%; repaired wire placement counting, danger-kill attribution, partial legacy link migration, UUID validation, and debug-only achievement isolation. -- 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.78`. -- 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. - 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`. @@ -65,48 +52,10 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; - Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` -### v39.16.16 -Colony birth/death chart lines, reverse rotators, champion-trait inheritance, reduced champion-pair mating, and directional one-way-fence routing. - - -- 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.78) -- 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. -- v39.16.64 adds 毎日たりない観察(7 calendar days) and 抵抗器じゃない(7 distinct tarinai shocked by one energized bare wire). v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights. -- Persistent totals include 物でいっぱい, 害虫駆除, 大いなる母(3333体), 無計画都市, やっぱなし, and やっぱあり. Field-local progress includes 餌やりじいさん, 清掃業者, 自給自足, and 敵の敵は味方; applicable field-local metadata follows current-format saves. -- `自動化の第一歩` and `リンク職人` are retired and replaced by `機械化産業`, which requires both detector-driven activation and rope/rod/spring coverage. Existing players with both retired achievements migrate automatically. -- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v3 positional arrays. Compact v2 and the prior verbose object are accepted only as migration inputs and are automatically rewritten under a separate server lock with atomic file replacement on the next API read. UUIDs are binary/base64url encoded, achievement IDs are indexed, timestamps are delta-coded, and repeated game versions use a dictionary. -- On startup, shared-stat refresh, achievement-screen opening, and every five minutes, the client sends its complete non-debug unlock set with original local timestamps. Ordinary unlock bursts send only unsynchronized records after a short debounce. The server adds only missing records, so a lost `state.json` is rebuilt incrementally as players return without overwriting surviving records. - ### 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 sixty-four 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. +- 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`. -- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed and spell data is accepted only from the current format. -- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements. -- v39.16.62: Added スペックがたりない, 寝ろ, 砂場, そこ公共空間だよ?, and 破格の工事費用; 寿命 deaths no longer reset 安全第一; 何やってるの? now uses empty-field clicks; and save schema 44 expands the achievement mask while decoding schemas 41–43. - -- v39.16.63: Fixed achievement progress text weight and continuous-play tab switching; added アリ観察 and 命のバトン; corrected サウナ水風呂 and 敵の敵は味方 condition mismatches; save/spell data accepts only schema 45 and achievement payload v4. -- v39.16.64: Added 毎日たりない観察 and 抵抗器じゃない; introduced calendar-day streak persistence; and migrated server aggregate JSON to compact v3 positional data automatically on read. -- v39.16.65: Hardened pre-deployment server migration with read-only summaries, POST-only mutations, corruption-safe decoding, atomic writes under a separate lock, completionist eligibility filtering, and seeded randomized/concurrent migration audits. - -- v39.16.66: Added two-user seesaw play, an editable AND/OR/NOT circuit board with positioned terminals, signal control for fan/stove/dry ice/duplicator, and wire-target highlighting. - -- v39.16.67: Replaced the circuit-board dropdown editor with direct grid placement and cursor-drawn wiring; changed colony-state evaluation to every two in-game hours and excluded growing populations from Weakened; fixed seesaw action selection, fixed its orientation, and matched the placement overlay to its shape. -- v39.16.69: Reworked circuit-board wiring into straight grid segments with branches and multi-connect terminals; removed OR and added a rectifier; made internal parts rotatable and external terminal count/positions editable; expanded board terminal spacing; changed detector numeric conditions to min/max dual sliders with number inputs; and raised seesaw riders slightly above the seats. -- v39.16.68: Fixed seesaw participation so idle/bored Tarinai can initiate play and a waiting rider strongly recruits a partner; selected data reports active play as 「シーソーをしている。」; moved seesaws behind sprite layers; and aligned all two-input circuit gate pins to the 40px wiring grid. -- v39.16.70: Reduced the default circuit-board footprint to one quarter of the previous area; made grid width/height editable; made external terminals movable on all four edges with a height-dependent count limit; made branch junctions movable; reduced NOT/rectifier body sizes; and corrected seesaw action-reason text. - -- v39.16.71: Replaced circuit-board terminal +/- controls with direct IN/OUT terminal placement and erasing, dimmed terminals without external world wires, removed generic elliptical placement previews, added an Electrical tool category for detector/wires/circuit board, and made detector range-track clicks move the nearest thumb. - -- 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.78: 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. The renewed achievement audit also fixes ミニマリスト save metadata, サウナ水風呂 hot-entry timing, lethal wire-shock counting, reset-scoped hidden progress cleanup, and 監視カメラ intervention gaps for Undo/Redo and ground changes. -- v39.16.76: 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/achievement_api.php b/achievement_api.php index 3b7a2c8..cedb961 100644 --- a/achievement_api.php +++ b/achievement_api.php @@ -10,6 +10,7 @@ const ACHIEVEMENT_IDS = [ 'natural_zunchi_slave', 'natural_tarinai_king', 'self_zunchi_death', + 'zunchi_collision_death', 'natural_zunchi_slave_5_generations', 'natural_tarinai_king_3_generations', 'death_50_in_10_seconds', @@ -188,7 +189,7 @@ function normalize_state(mixed $value): array { ]; } foreach ($value['u'] as $achievementId => $flat) { - if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue; + if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($flat)) continue; $records = []; $count = count($flat); for ($i = 0; $i + 1 < $count; $i += 2) { @@ -210,7 +211,7 @@ function normalize_state(mixed $value): array { } $rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; foreach ($rawUnlocks as $achievementId => $records) { - if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue; + if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue; foreach ($records as $id => $timestamp) { $normalizedId = strtolower((string)$id); if (!isset($players[$normalizedId])) continue; @@ -219,20 +220,6 @@ function normalize_state(mixed $value): array { } } - $legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first']) - ? $unlocks['signal_automation_first'] : []; - $legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman']) - ? $unlocks['link_craftsman'] : []; - if ($legacySignal && $legacyLinks) { - if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) { - $unlocks['mechanized_industry'] = []; - } - foreach ($legacySignal as $playerId => $signalTime) { - if (!isset($legacyLinks[$playerId])) continue; - $unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]); - } - } - unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']); return ['players' => $players, 'unlocks' => $unlocks]; } diff --git a/app_manifest.json b/app_manifest.json index 3dd37b6..67dee34 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.16.109", + "version": "39.16.137", "css": [ "css/base.css", "css/layout.css", @@ -58,6 +58,7 @@ "js/item_dynamic_duplicator_system.js", "js/item_dynamic_pin_system.js", "js/item_dynamic_zunchi_system.js", + "js/item_environment_hazard_system.js", "js/item_dynamic_system.js", "js/item_lifecycle_decay_system.js", "js/item_lifecycle_growth_system.js", @@ -69,6 +70,7 @@ "js/item_lifecycle_pipeline.js", "js/item_runtime.js", "js/structure_lifecycle.js", + "js/memorial_bell_system.js", "js/item_render_helpers.js", "js/item_render_runtime.js", "js/burn_motion_util.js", diff --git a/assets/sounds/bicycle_bell.mp3 b/assets/sounds/bicycle_bell.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6fef91af695b240f21c2f174de0c313a5f28e5d5 GIT binary patch literal 30092 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 U2|q0|{ohM4b^rhK|M(yHKP{H06aWAK literal 0 HcmV?d00001 diff --git a/css/base.css b/css/base.css index 91b9d49..1fbdde1 100644 --- a/css/base.css +++ b/css/base.css @@ -91,6 +91,11 @@ h1 { white-space: nowrap; } + +.speed-select-native { display: none; } +.speed-menu { position: relative; display: inline-block; } +.speed-menu-btn { min-width: 92px; text-align: center; white-space: nowrap; } + .top-actions { display: flex; gap: 6px; diff --git a/css/components.css b/css/components.css index a9ad7fb..75daf0a 100644 --- a/css/components.css +++ b/css/components.css @@ -27,6 +27,9 @@ gap: 8px; flex-wrap: wrap; margin: 8px 0 4px; + position: relative; + z-index: 4; + isolation: isolate; } .favorite-toggle { border: 1px solid rgba(128, 102, 56, 0.32); @@ -34,8 +37,15 @@ background: rgba(255, 248, 221, 0.92); color: #5a4630; font-weight: 700; - padding: 5px 10px; + padding: 7px 12px; cursor: pointer; + position: relative; + z-index: 5; + display: block; + width: 100%; + box-sizing: border-box; + pointer-events: auto; + touch-action: manipulation; } .favorite-toggle.active { background: rgba(255, 229, 122, 0.95); @@ -681,6 +691,10 @@ gap: 5px; } .top-menu-panel.hidden { display: none; } +.speed-menu-panel { width: 116px; min-width: 116px; } +.speed-menu-panel .top-menu-item { text-align: center; } +.speed-menu-panel .top-menu-item.active, +.speed-menu-panel .top-menu-item[aria-checked="true"] { background: rgba(224,242,201,.92); } .top-menu-item { width: 100%; min-height: 38px; diff --git a/css/layout.css b/css/layout.css index c05d30a..d0f4064 100644 --- a/css/layout.css +++ b/css/layout.css @@ -25,6 +25,7 @@ .tool[data-tool="delete"] { --tool-icon-zoom: 1.42; } .tool[data-tool="area_delete"] { --tool-icon-zoom: 1; } .tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; } +.tool[data-tool="bell"].tool-emoji-icon::before { font-size: 21px; z-index: 1; } .tool[data-tool="area_delete"].tool-emoji-icon::before { font-size: 31px; line-height: 1; @@ -125,6 +126,7 @@ box-shadow: 0 1px 4px rgba(42,36,29,0.16); pointer-events: auto; cursor: pointer; + z-index: 4; } .chart-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 3px 0 8px; } .chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; } @@ -353,3 +355,7 @@ meter { border-color: rgba(38,132,190,0.56); box-shadow: inset 0 0 0 1px rgba(84,176,226,0.20), 0 8px 20px rgba(59,133,177,0.10); } + +.chart-range-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: -2px 0 7px; } +.chart-range-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.48); border-radius: 8px; padding: 4px 4px; font-size: 10px; cursor: pointer; color: var(--muted); } +.chart-range-tabs button.active { background: rgba(239,255,222,0.92); border-color: rgba(103,178,44,0.42); color: var(--text); font-weight: 700; } diff --git a/css/panel.css b/css/panel.css index 766acea..762f2c2 100644 --- a/css/panel.css +++ b/css/panel.css @@ -119,6 +119,14 @@ body::before { pointer-events: none; } + +.family-death-time { + display: block; + margin-top: 1px; + font-size: 10px; + line-height: 1.15; + color: rgba(93, 72, 61, 0.76); +} .family-genetic-stats { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -260,8 +268,8 @@ body::before { .lineage-family-tree .tree-node { position: absolute; width: 150px; - height: 104px; - min-height: 104px; + height: 112px; + min-height: 112px; box-sizing: border-box; align-items: flex-start; background: linear-gradient(180deg, #fffdf8, #f8f1e5); @@ -280,6 +288,29 @@ body::before { .lineage-family-tree .tree-node[data-family-tarinai-id]:hover { transform: none; } +.lineage-family-tree.family-hover-active .tree-node.family-hover-dim { + opacity: 0.28; + filter: saturate(0.58); + transition: opacity .12s ease, filter .12s ease, box-shadow .12s ease, border-color .12s ease; +} +.lineage-family-tree.family-hover-active .tree-node.family-hover-focus, +.lineage-family-tree.family-hover-active .tree-node.family-hover-parent, +.lineage-family-tree.family-hover-active .tree-node.family-hover-child { + opacity: 1; + filter: none; +} +.lineage-family-tree.family-hover-active .tree-node.family-hover-focus { + border-color: rgba(124, 165, 66, 0.78); + box-shadow: 0 0 0 3px rgba(124, 165, 66, 0.16), 0 5px 13px rgba(64,48,32,0.10); +} +.lineage-family-tree.family-hover-active .tree-node.family-hover-parent { + border-color: rgba(80, 132, 198, 0.72); + box-shadow: 0 0 0 2px rgba(80, 132, 198, 0.14); +} +.lineage-family-tree.family-hover-active .tree-node.family-hover-child { + border-color: rgba(87, 170, 91, 0.72); + box-shadow: 0 0 0 2px rgba(87, 170, 91, 0.14); +} .lineage-family-tree .tree-node .family-portrait { right: 4px; top: 4px; @@ -319,7 +350,7 @@ body::before { /* Restored from the original ZIP: fixed colony graph/legend sizing. */ .colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; } .colony-stats { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr; margin-bottom: 8px; } .colony-chart { @@ -442,3 +473,12 @@ body::before { line-height: 9px !important; font-size: 6.8px !important; } + +.colony-chart { position: relative; } +.colony-chart-tooltip { position: fixed; z-index: 10020; pointer-events: none; min-width: 112px; max-width: 210px; padding: 7px 8px; border-radius: 9px; border: 1px solid rgba(80,65,48,0.18); background: rgba(255,252,245,0.98); color: #4d443a; box-shadow: 0 4px 14px rgba(64,48,30,0.16); font-size: 10px; line-height: 1.45; transform: none; } +.colony-chart-tooltip.hidden { display: none; } +.colony-chart-tooltip strong { display: block; margin-bottom: 3px; font-size: 10.5px; } +.colony-chart-tooltip-row { display: flex; justify-content: space-between; gap: 10px; white-space: nowrap; } +.colony-chart-hover-point { position: absolute; z-index: 5; width: 8px; height: 8px; border: 2px solid #5d8ee6; border-radius: 50%; background: rgba(255,255,255,0.96); box-shadow: 0 0 0 2px rgba(255,255,255,0.7); pointer-events: none; transform: translate(-50%, -50%); } +.colony-chart-hover-point.hidden { display: none; } + diff --git a/index.html b/index.html index 8e2e0bc..6c3a7ec 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -40,14 +40,27 @@
- +
+ + + +
+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js index 6dcbda4..65f7398 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -20,7 +20,7 @@ const WIRE_SHOCK_TARGET = 7; const DAY_MS = 24 * 60 * 60 * 1000; const ROBOT_CLEAN_TARGET = 200; - const ROBOT_ALL_TARGET_MASK = 0x3f; + const ROBOT_ALL_TARGET_MASK = 0x7f; const SYNC_DEBOUNCE_MS = 450; const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000; const SPELL_STATE_VERSION = 8; @@ -65,7 +65,10 @@ "elite_few", "lively_making", "memento_mori", - "mad_scientist", + "rain_shelter_all", + "self_zunchi_death", + "zunchi_collision_death", + "megalopolis", ]); const ACHIEVEMENT_CATEGORIES = Object.freeze([ Object.freeze({ @@ -95,7 +98,7 @@ id: "experiment", title: "\u5b9f\u9a13", ids: Object.freeze([ - "self_zunchi_death", "death_50_in_10_seconds", "soccer_ball_death", + "self_zunchi_death", "zunchi_collision_death", "death_50_in_10_seconds", "soccer_ball_death", "fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation", "ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge", "mercury_lifespan", "revolution", "fuel_to_fire", @@ -134,11 +137,12 @@ Object.freeze({ id: "natural_zunchi_slave", title: "\u305a\u3093\u3061\u3069\u308c\u3044", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u51fa\u73fe\u3059\u308b\u3002" }), Object.freeze({ id: "natural_tarinai_king", title: "\u305f\u308a\u306a\u3044\u738b", description: "\u305f\u308a\u306a\u3044\u738b\u304c\u51fa\u73fe\u3059\u308b\u3002" }), Object.freeze({ id: "self_zunchi_death", title: "\u3042\u3041\uff3e\uff5e \u305f\u307e\u3089\u306d\u3047\u305c", description: "\u304a\u3057\u308a\u92f2\u3092\u629c\u3044\u305f\u6642\u3001\u81ea\u5206\u306e\u305a\u3093\u3061\u3067\u81ea\u7206\u3059\u308b\u3002" }), + Object.freeze({ id: "zunchi_collision_death", title: "\u304f\u3061\u3083\u3044\u306e\u3060\uff01", description: "\u305a\u3093\u3061\u3068\u306e\u885d\u7a81\u3067\u6b7b\u4ea1\u3059\u308b\u500b\u4f53\u304c\u51fa\u73fe\u3059\u308b\u3002" }), Object.freeze({ id: "natural_zunchi_slave_5_generations", title: "\u672b\u4ee3\u307e\u3067\u306e\u6065", description: "5\u4ee3\u9023\u7d9a\u3067\u305a\u3093\u3061\u3069\u308c\u3044\uff08\u9396\u306a\u3057\uff09" }), Object.freeze({ id: "natural_tarinai_king_3_generations", title: "\u83ef\u9e97\u306a\u308b\u738b\u65cf", description: "3\u4ee3\u9023\u7d9a\u3067\u305f\u308a\u306a\u3044\u738b\uff08\u738b\u51a0\u306a\u3057\uff09" }), Object.freeze({ id: "death_50_in_10_seconds", title: "6\u756a\u76ee\u306e\u5927\u91cf\u7d76\u6ec5", description: "10\u79d2\u4ee5\u5185\u306b50\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }), Object.freeze({ id: "birth_50_in_60_seconds", title: "\u30d9\u30d3\u30fc\u30d6\u30fc\u30e0", description: "1\u5206\u4ee5\u5185\u306b30\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u8a95\u751f\u3059\u308b\u3002" }), - Object.freeze({ id: "great_mother_1000_births", title: "\u5927\u3044\u306a\u308b\u6bcd", description: "\u305f\u308a\u306a\u3044\u3092\u7e41\u6b96\u3067\u7d2f\u8a083333\u4f53\u8a95\u751f\u3055\u305b\u308b\u3002" }), + Object.freeze({ id: "great_mother_1000_births", title: "\u6bcd\u306a\u308b\u5927\u5730", description: "\u305f\u308a\u306a\u3044\u3092\u7e41\u6b96\u3067\u7d2f\u8a083333\u4f53\u8a95\u751f\u3055\u305b\u308b\u3002" }), Object.freeze({ id: "lifespan_completed", title: "\u5929\u5bff", description: "\u305f\u308a\u306a\u30441\u4f53\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }), Object.freeze({ id: "soccer_ball_death", title: "\u8c46\u91ce\u3001\u6bba\u30c3\u30ab\u30fc\u3084\u308d\u3046\u305c\uff01", description: "\u30b5\u30c3\u30ab\u30fc\u30dc\u30fc\u30eb\u3068\u306e\u885d\u7a81\u3067\u3001\u305f\u308a\u306a\u30441\u4f53\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }), Object.freeze({ id: "fight_pair_danger_kill", title: "\u55a7\u5629\u4e21\u6210\u6557", description: "\u55a7\u5629\u4e2d\u306e\u305f\u308a\u306a\u3044\u3092\u3001\u4e21\u65b9\u3068\u3082\u5371\u967a\u30ab\u30c6\u30b4\u30ea\u306e\u30a2\u30a4\u30c6\u30e0\u3067\u5012\u3059\u3002" }), @@ -249,7 +253,7 @@ let scheduledProgressSaveTimer = 0; function blankState() { - return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } }; + return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, directFeedCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } }; } function loadState() { @@ -261,15 +265,6 @@ for (const [id, timestamp] of Object.entries(unlockedSource)) { if (definitionById.has(id) && Number(timestamp) > 0) unlocked[id] = Number(timestamp); } - const legacyMechanizedAt = Math.max( - Number(unlockedSource.signal_automation_first || 0) || 0, - Number(unlockedSource.link_craftsman || 0) || 0 - ); - if (!unlocked.mechanized_industry - && Number(unlockedSource.signal_automation_first || 0) > 0 - && Number(unlockedSource.link_craftsman || 0) > 0) { - unlocked.mechanized_industry = legacyMechanizedAt || Date.now(); - } const debugUnlockedSource = Array.isArray(parsed.debugUnlocked) ? parsed.debugUnlocked : []; const debugUnlocked = [...new Set(debugUnlockedSource.filter(id => definitionById.has(id)))]; if (!DEBUG_MODE) { @@ -277,21 +272,17 @@ } const pendingSource = Array.isArray(parsed.pending) ? parsed.pending : []; const pending = pendingSource.filter(id => definitionById.has(id) && !debugUnlocked.includes(id)); - if (unlocked.mechanized_industry && !pending.includes("mechanized_industry") - && (pendingSource.includes("signal_automation_first") || pendingSource.includes("link_craftsman"))) { - pending.push("mechanized_industry"); - } const progressSource = parsed.progress && typeof parsed.progress === "object" ? parsed.progress : {}; - const legacyLinkTypes = Number(unlockedSource.link_craftsman || 0) > 0 ? ["rope", "rod", "spring"] : []; const progress = { placementCount: Math.max(0, Math.floor(Number(progressSource.placementCount || 0) || 0)), - linkTypes: [...new Set([...(Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []), ...legacyLinkTypes].filter(type => ["rope", "rod", "spring"].includes(type)))], - signalActivated: Boolean(progressSource.signalActivated || unlockedSource.signal_automation_first), + linkTypes: [...new Set((Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []).filter(type => ["rope", "rod", "spring"].includes(type)))], + signalActivated: Boolean(progressSource.signalActivated), antKills: Math.max(0, Math.floor(Number(progressSource.antKills || 0) || 0)), birthCount: Math.max(0, Math.floor(Number(progressSource.birthCount || 0) || 0)), quickDeleteCount: Math.max(0, Math.floor(Number(progressSource.quickDeleteCount || 0) || 0)), undoCount: Math.max(0, Math.floor(Number(progressSource.undoCount || 0) || 0)), redoCount: Math.max(0, Math.floor(Number(progressSource.redoCount || 0) || 0)), + directFeedCount: Math.min(33, Math.max(0, Math.floor(Number(progressSource.directFeedCount || 0) || 0))), medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))], firstAidHeals: Math.max(0, Math.floor(Number(progressSource.firstAidHeals || 0) || 0)), fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)), @@ -495,6 +486,7 @@ Math.min(STATISTICIAN_TARGET, Math.max(0, Number(state.progress?.statsButtonPressCount || 0) || 0)), Math.min(MEMENTO_MORI_TARGET, Math.max(0, Number(state.progress?.totalDeathCount || 0) || 0)), Math.min(LIVELY_MAKING_TARGET, Math.max(0, Number(state.progress?.manualTarinaiAddedCount || 0) || 0)), + Math.min(33, Math.max(0, Number(state.progress?.directFeedCount || 0) || 0)), Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0), Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10), ].join(":"); @@ -918,6 +910,7 @@ state.progress.quickDeleteCount = Math.max(0, Math.floor(Number(state.progress.quickDeleteCount || 0) || 0)); state.progress.undoCount = Math.max(0, Math.floor(Number(state.progress.undoCount || 0) || 0)); state.progress.redoCount = Math.max(0, Math.floor(Number(state.progress.redoCount || 0) || 0)); + state.progress.directFeedCount = Math.min(33, Math.max(0, Math.floor(Number(state.progress.directFeedCount || 0) || 0))); if (!Array.isArray(state.progress.medicineTypes)) state.progress.medicineTypes = []; state.progress.medicineTypes = [...new Set(state.progress.medicineTypes.filter(type => MEDICINE_LEDGER_TYPES.includes(type)))]; state.progress.firstAidHeals = Math.max(0, Math.floor(Number(state.progress.firstAidHeals || 0) || 0)); @@ -1003,6 +996,7 @@ state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount), state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount), state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length), + state.unlocked.direct_feed_33 ? 0 : Math.min(33, progress.directFeedCount), ], ]; } @@ -1100,6 +1094,7 @@ while (progress.mysteryDrugTarinaiIds.length < importedMysteryDrugCount) { progress.mysteryDrugTarinaiIds.push(`imported:${progress.mysteryDrugTarinaiIds.length + 1}`); } + progress.directFeedCount = Math.max(progress.directFeedCount, Math.min(33, Math.max(0, Math.floor(Number(packed[18] || 0) || 0)))); const progressChanged = previousProgress !== JSON.stringify(progress); changed = changed || progressChanged; @@ -1127,7 +1122,9 @@ recordIntervention({ ...detail, world }); const item = detail.item || null; if (item) { + if (!item._achievementPlayerPlaced || item._achievementPlayerPlacedCounted === false) world.achievementPlayerPlacedActiveCount = Math.max(0, Number(world.achievementPlayerPlacedActiveCount || 0) || 0) + 1; item._achievementPlayerPlaced = true; + item._achievementPlayerPlacedCounted = true; item._achievementPlacedAt = Math.max(0, Number(world.time || 0) || 0); } let changed = evaluateCleanFreak(world, detail); @@ -1226,7 +1223,7 @@ function evaluateAntNestWithoutTarinai(worldRef = global.world, detail = {}) { if (!worldRef || state.unlocked.ant_nest_without_tarinai) return false; - const hasNest = (worldRef.items || []).some(item => item && !item.dead && item.type === "ant_nest"); + const hasNest = playstyleItemCount(worldRef, "ant_nest") > 0; if (!hasNest) return false; const aliveTarinai = (worldRef.tarinai || []).some(tarinai => tarinai && !tarinai.dead); if (aliveTarinai) return false; @@ -1439,7 +1436,7 @@ function evaluateSandbox(world = global.world, detail = {}) { if (!world || state.unlocked.sandbox_five_toilets) return false; - const count = (world.items || []).reduce((sum, item) => sum + (item && !item.dead && item.type === "toilet" ? 1 : 0), 0); + const count = playstyleItemCount(world, "toilet"); if (count < 5) return false; return unlock("sandbox_five_toilets", { ...detail, world, count }); } @@ -1500,11 +1497,30 @@ function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) { if (state.unlocked.below_absolute_zero_item || !worldRef) return false; - for (const item of worldRef.items || []) { - if (!item || item.dead) continue; + worldRef.ensureItemBuckets?.("achievement-absolute-zero"); + const testItem = (item) => { + if (!item || item.dead) return false; const temperature = Number(worldRef.temperatureAt?.(item.x, item.y, null)); - if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) continue; + if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) return false; return unlock("below_absolute_zero_item", { ...detail, world: worldRef, item, temperature }); + }; + const ambient = Number(worldRef.currentTemperature); + if (Number.isFinite(ambient) && ambient < ABSOLUTE_ZERO_C) { + for (const bucket of worldRef.itemTypeBuckets?.values?.() || []) { + for (const item of bucket || []) if (testItem(item)) return true; + } + return false; + } + const seen = new Set(); + for (const sourceType of ["dry_ice", "fan"]) { + for (const source of worldRef.itemsOfType?.(sourceType) || []) { + if (!source || source.dead) continue; + for (const item of worldRef.nearbyNonGrassItems?.(source.x, source.y, 360) || []) { + if (!item || item.dead || seen.has(item)) continue; + seen.add(item); + if (testItem(item)) return true; + } + } } return false; } @@ -1527,8 +1543,10 @@ function evaluateCleanFreak(world = global.world, detail = {}) { if (!world || state.unlocked.clean_freak_robot_only) return false; - const liveItems = (world.items || []).filter(item => item && !item.dead); - if (!liveItems.length || liveItems.some(item => item.type !== "robot_cleaner")) return false; + const activeObjects = Math.max(0, Number(world.activeObjectCount?.() ?? (world.items || []).reduce((count, item) => count + (item && !item.dead ? 1 : 0), 0)) || 0); + const robotCount = Math.max(0, Number(world.itemCounts?.robot_cleaner || 0) || 0); + if (!activeObjects || robotCount !== activeObjects) return false; + const liveItems = world.itemsOfType?.("robot_cleaner") || []; if ((world.tarinai || []).some(tarinai => tarinai && !tarinai.dead)) return false; if ((world.ants || []).some(ant => ant && !ant.dead)) return false; const qualifies = liveItems.some(robot => { @@ -1706,7 +1724,6 @@ if (!world) return false; runtimeByWorld.delete(world); dangerFightDeathsByWorld.delete(world); - world.achievementDirectFeedCount = 0; world.achievementRobotCleanCount = 0; world.achievementEnemyAntKills = 0; world.achievementLastTarinaiDamageAt = -Infinity; @@ -1734,6 +1751,14 @@ } world.achievementNaturalSlaveGenerations = []; world.achievementNaturalKingGenerations = []; + if (!state.unlocked.mad_scientist) { + const progress = ensureProgress(); + if (progress.mysteryDrugTarinaiIds.length) { + progress.mysteryDrugTarinaiIds = []; + saveState(); + renderIfDialogOpen(); + } + } return true; } @@ -1852,9 +1877,11 @@ if (!world) return false; recordDirectCare({ ...detail, world, foodGift: true }); if (state.unlocked.direct_feed_33) return false; - world.achievementDirectFeedCount = Math.min(33, Math.max(0, Math.floor(Number(world.achievementDirectFeedCount || 0) || 0)) + 1); + const progress = ensureProgress(); + progress.directFeedCount = Math.min(33, progress.directFeedCount + 1); + saveState(); renderIfDialogOpen(); - if (world.achievementDirectFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: world.achievementDirectFeedCount }); + if (progress.directFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: progress.directFeedCount }); return false; } @@ -2009,7 +2036,7 @@ const moodId = String(mood?.id || worldRef.colonyMood?.id || ""); const day = Math.max(1, Math.floor(Number(worldRef.day || 1) || 1)); if (alive.length > 0 && moodId === "happy" && day >= 6 && !state.unlocked.minimalist_happy) { - const placedAlive = (worldRef.items || []).reduce((count, item) => count + (item && !item.dead && item._achievementPlayerPlaced ? 1 : 0), 0); + const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount ?? worldRef.achievementPlayerPlacementCount ?? 0) || 0); if (placedAlive <= 10) unlock("minimalist_happy", { world: worldRef, mood: mood || worldRef.colonyMood, placedAlive }); } if (!state.unlocked.colony_happy) { @@ -2027,7 +2054,7 @@ } if (!state.unlocked.self_sufficient) { - if (alive.length >= 20 && !(worldRef.items || []).some(item => item && !item.dead && item.type === "duplicator")) { + if (alive.length >= 20 && playstyleItemCount(worldRef, "duplicator") === 0) { let selfStart = Number(worldRef.achievementSelfSufficientStartAt); if (!Number.isFinite(selfStart) || selfStart < 0 || selfStart > now) { selfStart = now; @@ -2264,6 +2291,7 @@ evaluateWorld, evaluateEvent, recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); }, + recordZunchiCollisionDeath(detail = {}) { return unlock("zunchi_collision_death", detail); }, recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); }, }); global.TarinaiAchievements = api; diff --git a/js/assets.js b/js/assets.js index ebf8742..54b6a0e 100644 --- a/js/assets.js +++ b/js/assets.js @@ -77,7 +77,7 @@ let imageLoadTimeoutsTotal = 0; const SUNBATH_IMAGE_IDS = new Set(SPRITES .map(asset => asset?.id || "") .filter(id => String(id).startsWith("sunbath"))); -const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); +const INITIAL_IMAGE_IDS = new Set(["smile", "sleep", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); function trimCanvasCache(cache, limit = 128) { diff --git a/js/audio.js b/js/audio.js index 99402d6..32e55c4 100644 --- a/js/audio.js +++ b/js/audio.js @@ -314,6 +314,7 @@ const audio = { waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); }, shoot() { this.play("sfx_shoot", { category: "ops", minGap: 0.045 }); }, boltAction() { this.play("sfx_bolt_action", { category: "ops", minGap: 0.10 }); }, + bell() { if (!this.playSample("sfx_bell", 0.16, "ops")) this.play("sfx_bell", { category: "ops", minGap: 0.16 }); }, ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); }, stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); }, genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); }, diff --git a/js/circuit_board_system.js b/js/circuit_board_system.js index 6f5a470..1e49b9a 100644 --- a/js/circuit_board_system.js +++ b/js/circuit_board_system.js @@ -565,8 +565,6 @@ return null; } - const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by); - function wirePath(config, wire) { const a = endpointPosition(config, wire.a); const b = endpointPosition(config, wire.b); @@ -579,7 +577,7 @@ for (let i = config.wires.length - 1; i >= 0; i -= 1) { const path = wirePath(config, config.wires[i]); if (path.length < 2) continue; - const d = distanceToSegment(x, y, path[0].x, path[0].y, path[1].x, path[1].y); + const d = global.TarinaiGeometry.pointSegmentDistance(x, y, path[0].x, path[0].y, path[1].x, path[1].y); if (d <= bestD) { bestD = d; best = { wire: config.wires[i], index: i, distance: d }; } } return best; @@ -608,7 +606,7 @@ } function pointOnSegment(point, a, b) { - return distanceToSegment(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6 + return global.TarinaiGeometry.pointSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6 && point.x >= Math.min(a.x, b.x) - 0.6 && point.x <= Math.max(a.x, b.x) + 0.6 && point.y >= Math.min(a.y, b.y) - 0.6 && point.y <= Math.max(a.y, b.y) + 0.6; } diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index 6bf87c4..e6010e7 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -52,7 +52,7 @@ function isSoftPlacementIgnoredType(type = "") { const key = String(type || ""); - return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water"; + return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water" || key === "grave"; } function placementRectsFor(worldRef, item) { diff --git a/js/command_dispatcher.js b/js/command_dispatcher.js index 8e26e9f..fd296cd 100644 --- a/js/command_dispatcher.js +++ b/js/command_dispatcher.js @@ -102,7 +102,6 @@ function normalizeToolPickMode(mode = "free") { const value = String(mode || "free").toLowerCase(); - if (value === "grid") return "grid40"; // compatibility with v39.16.34 return DEFAULT_PICK_MODE_ORDER.includes(value) ? value : "free"; } @@ -139,6 +138,15 @@ return ok({ speed: worldRef.speed }); } + function setSpeed(worldRef, speed, allowed = [0.5, 1, 2, 4, 8, 16]) { + if (!worldRef) return fail("missing_world"); + const value = Number(speed); + const list = Array.isArray(allowed) ? allowed.map(Number).filter(Number.isFinite) : [0.5, 1, 2, 4, 8, 16]; + if (!Number.isFinite(value) || !list.includes(value)) return fail("invalid_speed", { speed }); + worldRef.speed = value; + return ok({ speed: worldRef.speed }); + } + function primaryPointer(worldRef, x, y) { if (!worldRef) return fail("missing_world"); const px = Number(x); @@ -171,6 +179,8 @@ return togglePaused(worldRef); case "simulation.speed.cycle": return cycleSpeed(worldRef, command.speeds); + case "simulation.speed.set": + return setSpeed(worldRef, command.speed, command.allowed); case "pointer.primary": return primaryPointer(worldRef, command.x, command.y); case "history.undo": { diff --git a/js/data.js b/js/data.js index e32a970..2dd404c 100644 --- a/js/data.js +++ b/js/data.js @@ -51,7 +51,7 @@ const CONFIG = { hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, energyPerMinute: 8.2, - reproductionCooldown: 30, + reproductionCooldown: 39, dayLength: 120, standardTemperature: 15, summerTemperatureBoost: 0, diff --git a/js/deterministic_helpers.js b/js/deterministic_helpers.js index d280a6e..8405fc9 100644 --- a/js/deterministic_helpers.js +++ b/js/deterministic_helpers.js @@ -68,7 +68,6 @@ birthHash32, isEffectCauseText, }); - global.isTarinaiEffectCauseText = global.isTarinaiEffectCauseText || isEffectCauseText; })(typeof window !== "undefined" ? window : globalThis); // Keep the historical bare high-level helper names available to legacy scripts while the diff --git a/js/health.js b/js/health.js index 1218c08..48456c6 100644 --- a/js/health.js +++ b/js/health.js @@ -34,6 +34,8 @@ const HEALTH = (() => { heatstroke: "\u71B1\u4E2D\u75C7", cleaning: "\u6383\u9664", electrocution: "\u611f\u96fb", + trap: "\u7f60\u306b\u6355\u307e\u3063\u305f", + poisonGas: "\u6bd2\u30ac\u30b9", }); const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; @@ -41,6 +43,8 @@ const HEALTH = (() => { const text = String(reason || ""); if (!text) return ""; if (/\u6383\u9664|\u30ed\u30dc\u6383\u9664\u6a5f|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning; + if (/\u7f60\u306b\u6355\u307e\u3063\u305f|bear.?trap|trapped/i.test(text)) return CAUSES.trap; + if (/\u6bd2\u30ac\u30b9|poison.?gas/i.test(text)) return CAUSES.poisonGas; if (/\u611f\u96fb|\u901a\u96fb\u4e2d\u306e\u96fb\u7dda|electrocution|electric.?shock|shocked/i.test(text)) return CAUSES.electrocution; if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze; diff --git a/js/impact_response_system.js b/js/impact_response_system.js index 0d81576..c821619 100644 --- a/js/impact_response_system.js +++ b/js/impact_response_system.js @@ -82,6 +82,14 @@ } if (ballHit > 0) worldRef.markSpatialDirty?.("genkotsu-ball-impact"); } + for (const grave of worldRef.itemsOfType?.("grave") || (worldRef.items || []).filter(item => item && item.type === "grave")) { + if (!grave || grave.dead) continue; + const d = Math.max(1, distXY(grave.x, grave.y, it.x, it.y)); + const p = clamp(1 - d / (radius + (grave.r || 18)), 0, 1); + if (p <= 0) continue; + const force = push * (0.35 + p * 1.15); + global.TarinaiMemorialBellSystem?.applyExternalForce?.(worldRef, grave, force, it, "drop-impact"); + } const antHit = damage > 0 ? worldRef.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.62 + p * 1.25), source: it, diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index b513735..baddd74 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -30,7 +30,8 @@ resolveObstacleCollisions(item, dt, worldRef); item.x = clamp(item.x, p, worldRef.w - p); item.y = clamp(item.y, p, worldRef.h - p); - const groundFriction = Math.pow(0.72, dt); + const oilMul = global.TarinaiItemEnvironmentHazardSystem?.groundFrictionMultiplier?.(worldRef, item.x, item.y) ?? 1; + const groundFriction = Math.pow(1 - (1 - 0.72) * oilMul, dt); item.vx *= groundFriction; item.vy *= groundFriction; } @@ -216,7 +217,7 @@ const now = worldRef?.time || 0; if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return; item.lastObstacleBounceAt = now; - const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)")); + const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(235,104,165,0.50)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)")); worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }); } diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index 3b8eb7e..59bdcb4 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -7,6 +7,12 @@ function update(item, dt, worldRef) { if (!item || item.dead) return false; switch (item.type) { + case "oil": + case "bear_trap": + case "leakage_device": + case "poison_gas_can": + case "poison_gas": + return global.TarinaiItemEnvironmentHazardSystem.update(item, dt, worldRef) !== false; case "firecracker": case "flame_firecracker": case "sticky_bomb": diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js index fab788e..b754207 100644 --- a/js/item_dynamic_zunchi_system.js +++ b/js/item_dynamic_zunchi_system.js @@ -93,6 +93,9 @@ && item.achievementBurstNeverStopped !== false ); worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81"); + if (t.dead) { + globalThis.TarinaiAchievements?.recordZunchiCollisionDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed }); + } if (t.dead && eligibleSelfZunchiDeath) { globalThis.TarinaiAchievements?.recordSelfZunchiDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed }); } diff --git a/js/item_environment_hazard_system.js b/js/item_environment_hazard_system.js new file mode 100644 index 0000000..d147a02 --- /dev/null +++ b/js/item_environment_hazard_system.js @@ -0,0 +1,382 @@ +"use strict"; +(function(global){ + const LOOSE_DYNAMIC_TYPES = new Set([ + "ball","balloon","zunchi","pushpin","oshibyo","genkotsu", + "firecracker","flame_firecracker","sticky_bomb","water" + ]); + + function nearby(world,x,y,r){ return world?.nearbyItems?.(x,y,r,true) || world?.items || []; } + function oilAt(world,x,y){ + for(const o of (world?.itemsOfType?.("oil")||[])) { + if(o&&!o.dead&&distXY(x,y,o.x,o.y)<=Math.max(12,o.r||58)) return o; + } + return null; + } + function groundFrictionMultiplier(world,x,y){ return oilAt(world,x,y)?0:1; } + + function spawnOilFire(item,world,initial=false){ + if(!item||!world)return null; + const r=Math.max(24,item.r||58); + const angle=initial ? global.deterministicAngle?.(world,"oil-ignite-fire",item,item.oilBurnTimer||0) ?? 0 : Math.random()*Math.PI*2; + const distance=initial ? 0 : Math.random()*r*.58; + return global.TarinaiFireRuntimeSystem?.spawnFireAt?.( + world, + item.x+Math.cos(angle)*distance, + item.y+Math.sin(angle)*distance*.58, + {radius:initial?20:12+Math.random()*5,amount:initial?72:30+Math.random()*24,life:initial?4.4:2.8+Math.random()*2.0,source:item,mergeRadius:initial?48:42} + ) || null; + } + + function igniteOil(item,world){ + if(!item||item.dead||item.type!=="oil"||item.oilBurning)return false; + item.oilBurning=true; + item.oilBurnTimer=0; + item.nextOilFireAt=Number(world?.time||0); + spawnOilFire(item,world,true); + if(world){ world.drawListDirty=true; world.markSpatialDirty?.("oil-ignited"); } + return true; + } + function igniteOilNear(world,x,y,r=120){ + let n=0; + for(const o of (world?.itemsOfType?.("oil")||[])){ + if(o&&!o.dead&&distXY(x,y,o.x,o.y)<=r+Math.max(12,o.r||58)) n+=igniteOil(o,world)?1:0; + } + return n; + } + + function igniteFromBurningOilContact(item,world){ + if(!item||!world||!item.oilBurning)return 0; + const radius=Math.max(24,Number(item.r||58)); + let ignited=0; + const tarinai=world.nearbyTarinai?.(item.x,item.y,radius+48,true)||world.tarinai||[]; + for(const t of tarinai){ + if(!t||t.dead||t.insideNestBoxId||world.isTarinaiHiddenInNestBox?.(t))continue; + if(distXY(item.x,item.y,t.x,t.y)>radius+Math.max(12,Number(t.radius||18))*.72)continue; + const wasBurning=Boolean(t.burning||(t.burnTimer||0)>.02); + if(typeof t.igniteFire==="function")t.igniteFire(item,false); + if(!wasBurning&&Boolean(t.burning||(t.burnTimer||0)>.02))ignited++; + } + const ants=world.nearbyAnts?.(item.x,item.y,radius+24,true)||world.ants||[]; + for(const ant of ants){ + if(!ant||ant.dead||distXY(item.x,item.y,ant.x,ant.y)>radius+Math.max(4,Number(ant.r||5)))continue; + const wasBurning=Boolean(ant.burning||(ant.burnTimer||0)>.02); + global.TarinaiFireRuntimeSystem?.igniteAntByFire?.(ant,item,world); + if(!wasBurning&&Boolean(ant.burning||(ant.burnTimer||0)>.02))ignited++; + } + return ignited; + } + function updateOil(item,dt,world){ + if(!item.oilBurning){ + for(const f of (world?.itemsOfType?.("fire")||[])){ + if(f&&!f.dead&&distXY(item.x,item.y,f.x,f.y)<=Math.max(item.r||58,24)+Math.max(f.r||14,10)){ + igniteOil(item,world); break; + } + } + return true; + } + const now=Number(world?.time||0); + igniteFromBurningOilContact(item,world); + item.oilBurnTimer=(item.oilBurnTimer||0)+dt; + item.amount=Math.max(0,(item.amount||100)-dt*5.2); + if(now>=Number(item.nextOilFireAt||0)){ + item.nextOilFireAt=now+0.36+Math.random()*0.34; + spawnOilFire(item,world,false); + } + if(item.amount<=0){ + item.amount=0; + world?.markItemBucketsDirty?.("oil-burnout"); + world?.markSpatialDirty?.("oil-burnout"); + world && (world.drawListDirty=true); + } + return true; + } + + function isTrappableDynamic(it,world){ + if(!it||it.dead||it.type==="bear_trap"||it.type==="poison_gas"||it.type==="fire") return false; + if(it.isStructure) return false; + if(["rope","rod","spring","wire","insulated_wire","rotator","reciprocator","poison_block"].includes(it.type)) return false; + if(LOOSE_DYNAMIC_TYPES.has(it.type)) return true; + if((it.dropTimer||0)>0 || Math.hypot(it.vx||0,it.vy||0)>0.2) return true; + return Boolean(world?.spatial?.isDynamicItem?.(it) && !it.isStructure); + } + + function trapTarget(item,world){ + const ref=item?.trapTargetRef; + if(ref&&!ref.dead&&(!item.trapTargetId||ref.id===item.trapTargetId)) return ref; + if(!item?.trapTargetId) return null; + const found=world?.itemById?.(item.trapTargetId) + || (world?.items||[]).find(it=>it&&!it.dead&&it.id===item.trapTargetId) + || (world?.tarinai||[]).find(t=>t&&!t.dead&&t.id===item.trapTargetId) + || null; + item.trapTargetRef=found; + return found; + } + + function hasTrapDamageStat(target){ + if(!target||target.dead)return false; + if(Number.isFinite(Number(target.hp))||Number.isFinite(Number(target.health)))return true; + if(Number.isFinite(Number(target.maxHp))||Number.isFinite(Number(target.maxHealth)))return true; + if(typeof target.damage==="function"&&Number.isFinite(Number(target.energy)))return true; + return false; + } + + function damageHeld(target,world,trap){ + if(!target||target.dead||!hasTrapDamageStat(target))return false; + if(typeof target.damage==="function"){ + try { + target.damage(2,"\u7f60\u306b\u6355\u307e\u3063\u305f",{source:trap}); + } catch (_) { + target.damage(2,"\u7f60\u306b\u6355\u307e\u3063\u305f",{source:trap}); + } + if (target.world && Number.isFinite(Number(target.radius))) target.hurtTimer=Math.max(Number(target.hurtTimer||0),.62); + return true; + } + if(Number.isFinite(Number(target.hp))){ + target.hp=Math.max(0,Number(target.hp)-2); + target.hpBarTimer=Math.max(Number(target.hpBarTimer||0),.8); + if(target.hp<=0) global.TarinaiStructureLifecycle?.deleteItem?.(world,target,{reason:"bear-trap",breaker:trap}); + return true; + } + if(Number.isFinite(Number(target.health))){ + target.health=Math.max(0,Number(target.health)-2); + return true; + } + return false; + } + + function holdTrapTarget(trap,target,world){ + if(!trap||!target||target.dead)return false; + const now=Number(world?.time||0); + target._bearTrapHoldUntil=Math.max(Number(target._bearTrapHoldUntil||0),Number(trap.trapUntil||now)); + target._bearTrapOwnerId=trap.id; + target._bearTrapHoldX=trap.x; + target._bearTrapHoldY=trap.y; + target.x=trap.x; + target.y=trap.y; + target.prevX=trap.x; + target.prevY=trap.y; + target.vx=0; target.vy=0; target.impulseVx=0; target.impulseVy=0; target.spinVelocity=0; + const body=global.TarinaiPhysicsBodySystem?.isBodyType?.(target) ? global.TarinaiPhysicsBodySystem.ensureBody?.(target,world,{syncFromLegacy:true}) : null; + if(body?.pose){ body.pose.x=trap.x; body.pose.y=trap.y; } + if(body?.velocity){ body.velocity.x=0; body.velocity.y=0; body.velocity.linear=0; body.velocity.angular=0; } + target._physicsExternalPoseDirty=true; + return true; + } + + function triggerBearTrap(item,target,world){ + if(!item||!target||target.dead)return false; + const now=Number(world?.time||0); + item.trapTargetId=target.id||""; + item.trapTargetRef=target; + item.trapTriggeredAt=now; + item.trapDamageableTarget=hasTrapDamageStat(target); + item.trapHoldDuration=item.trapDamageableTarget?10:5; + item.trapUntil=now+item.trapHoldDuration; + item.nextTrapDamageAt=now+.5; + item.trapClosed=true; + holdTrapTarget(item,target,world); + world?.queueEffect?.("ring",item.x,item.y,{size:Math.max(34,(item.r||34)*1.2),life:.22,color:"rgba(190,204,214,0.58)"}); + if(world){ world.drawListDirty=true; world.markSpatialDirty?.("bear-trap-trigger"); } + return true; + } + + function updateBearTrap(item,dt,world){ + const now=Number(world?.time||0); + if(item.trapClosed || item.trapUntil>0){ + const target=trapTarget(item,world); + if(now>=Number(item.trapUntil||0)){ + if(target){ + target._bearTrapHoldUntil=0; + target._bearTrapOwnerId=""; + target._bearTrapHoldX=undefined; + target._bearTrapHoldY=undefined; + } + item.trapTargetRef=null; + item.trapTargetId=""; + const removed=global.TarinaiStructureLifecycle?.deleteItem?.(world,item,{reason:"bear-trap-expired"}); + if(!removed)item.amount=0; + world?.markItemBucketsDirty?.("bear-trap-expired"); + world?.markSpatialDirty?.("bear-trap-expired"); + return true; + } + if(target){ + holdTrapTarget(item,target,world); + if(item.trapDamageableTarget&&now>=Number(item.nextTrapDamageAt||0)){ + item.nextTrapDamageAt=now+.5; + damageHeld(target,world,item); + } + } + return true; + } + const captureRadius=Math.max(46,(item.r||34)*1.45); + let best=null,bestD=Infinity; + for(const it of nearby(world,item.x,item.y,captureRadius)){ + if(it===item||!isTrappableDynamic(it,world))continue; + if(Number(it._bearTrapHoldUntil||0)>now)continue; + const d=distXY(item.x,item.y,it.x,it.y); + const rr=Math.max(12,(item.r||34)*.72)+Math.max(7,(it.r||12)*.62); + if(d<=rr&&dnow)continue; + const d=distXY(item.x,item.y,t.x,t.y); + const rr=Math.max(12,(item.r||34)*.72)+Math.max(10,(t.radius||18)*.62); + if(d<=rr&&d=Number(trap.trapUntil||0))continue; + const target=trapTarget(trap,world); + if(target&&holdTrapTarget(trap,target,world)) held++; + } + if(held) world?.markSpatialDirty?.("bear-trap-hold"); + return held; + } + + function leakageRadius(item){ return Math.max(260,(Number(item?.r)||34)*8.2); } + + function pulseLeakage(item,world){ + if(!item||item.dead||!world)return 0; + const powered=Boolean(item._signalDriven&&item._signalOn); + if(!powered)return 0; + const now=Number(world.time||0); + if(nowradius+(t.radius||18))continue; + const q=Math.max(.2,1-d/Math.max(1,radius)); + t.damage?.(1.5+.9*q,"\u6f0f\u96fb",{source:item}); + t.hurtTimer=Math.max(t.hurtTimer||0,.22); + t.electricShockUntil=Math.max(Number(t.electricShockUntil||0),now+.48); + t.setActionState?.("panic",{target:null,reason:"\u611f\u96fb\u3057\u3066\u3044\u308b\u3002",wake:true,sleeping:false}); + world.queueEffect?.("electric_shock",t.x,t.y-Math.max(2,(t.radius||18)*.15),{size:Math.max(18,(t.radius||18)*1.35),life:.32,color:"rgba(122,224,255,0.98)",importance:3,forceVisual:true}); + hit++; + } + for(const ant of world.ants||[]){ + if(!ant||ant.dead)continue; + const d=distXY(item.x,item.y,ant.x,ant.y); + if(d>radius+(ant.r||6))continue; + ant.hp=Math.max(0,Number(ant.hp??ant.maxHp??32)-1.4); + ant.hpBarTimer=Math.max(Number(ant.hpBarTimer||0),.7); + hit++; + } + world.queueEffect?.("ring",item.x,item.y,{size:radius*.62,life:.22,color:"rgba(112,206,255,0.38)"}); + world.drawListDirty=true; + return hit; + } + + function updateLeakage(item,dt,world){ + // Electrical damage is applied after the signal graph has been resolved for + // this simulation step. Keep the per-item update as a lightweight no-op. + return true; + } + + function updatePoweredHazards(world,dt){ + let hit=0; + for(const item of (world?.itemsOfType?.("leakage_device")||[])) hit+=pulseLeakage(item,world); + return hit; + } + + function spawnPoisonGas(world,can){ + if(!world||!can||can._gasReleased)return 0; + can._gasReleased=true; + let n=0; + const sizeScale=Math.max(.55,Math.min(1.8,Number(can.r||28)/28)); + const count=Math.max(7,Math.round(10*sizeScale)); + for(let i=0;i 0 ? "#ff4f5f" : "#6e2d37"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.moveTo(r * 0.35, -r * 0.91); + ctx.lineTo(r * 0.35 + Math.cos(-2.5 + fuseRatio * 2.0) * r * 0.12, -r * 0.91 + Math.sin(-2.5 + fuseRatio * 2.0) * r * 0.12); + ctx.stroke(); + // Corrosion and vent dots. + ctx.fillStyle = "rgba(66,42,31,0.54)"; + for (let i = 0; i < 7; i++) { + const a = (this.seed || 0) * 0.21 + i * 1.7; + ctx.beginPath(); + ctx.arc(Math.sin(a) * r * 0.43, Math.cos(a * 1.3) * r * 0.58, r * (0.035 + (i % 3) * 0.018), 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(225,205,118,0.88)"; + ctx.beginPath(); + ctx.moveTo(0, -r * 0.18); + ctx.lineTo(-r * 0.24, r * 0.18); + ctx.lineTo(r * 0.24, r * 0.18); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = "rgba(73,45,82,0.95)"; + ctx.beginPath(); + ctx.arc(0, r * 0.04, r * 0.06, 0, Math.PI * 2); + ctx.fill(); + const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5))); + ctx.fillStyle = "rgba(255,249,230,0.98)"; + ctx.strokeStyle = "rgba(53,31,61,0.98)"; + ctx.lineWidth = Math.max(2.4, r * 0.09); + ctx.font = `900 ${Math.max(12, r * 0.68)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.strokeText(timerText, 0, r * 0.08); + ctx.fillText(timerText, 0, r * 0.08); + ctx.restore(); + } else if (this.type === "poison_gas") { + const r = this.r || 96; + const c = Math.max(0, Math.min(1, this.gasConcentration ?? 1)); + ctx.save(); + ctx.globalAlpha *= 0.16 + 0.46 * c; + for (let i = 0; i < 7; i++) { + const a = (this.seed || 0) * 0.13 + i * 2.17; + const ox = Math.cos(a) * r * (0.16 + (i % 3) * 0.07); + const oy = Math.sin(a * 1.21) * r * (0.12 + (i % 2) * 0.07); + const rr = r * (0.48 + (i % 4) * 0.08); + const g = ctx.createRadialGradient(ox - rr * 0.18, oy - rr * 0.18, rr * 0.10, ox, oy, rr); + g.addColorStop(0, `rgba(198,112,226,${0.60 + c * 0.22})`); + g.addColorStop(0.54, `rgba(132,62,166,${0.34 + c * 0.28})`); + g.addColorStop(1, "rgba(83,39,112,0.02)"); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(ox, oy, rr, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); } else if (this.type === "fan") { const r = this.r || 26; const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this); @@ -1163,34 +1513,73 @@ draw(ctx, t, lighting = null) { const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1); const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0; const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5))); - ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12); - ctx.fillStyle = flash > 0.65 ? "#ffed77" : (flameMode ? "#d8241f" : "#5a4744"); - ctx.strokeStyle = flameMode ? "rgba(96,18,18,0.88)" : "rgba(35,30,29,0.88)"; - ctx.lineWidth = 2; - roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = flameMode ? "#ff6b32" : "#897065"; - ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20); - ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20); - ctx.strokeStyle = "rgba(48,38,31,0.86)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.moveTo(0, -this.r * 0.88); - ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42); - ctx.stroke(); - ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : (flameMode ? "rgba(255,80,40,0.78)" : "rgba(188,148,88,0.62)"); - ctx.beginPath(); - ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "rgba(255,246,214,0.92)"; - ctx.strokeStyle = flameMode ? "rgba(84,18,14,0.92)" : "rgba(24,22,21,0.92)"; + ctx.save(); + ctx.rotate(-0.18 + Math.sin(this.seed) * 0.10); + const bodyGrad = ctx.createLinearGradient(-this.r * 0.74, 0, this.r * 0.74, 0); + if (flameMode) { + bodyGrad.addColorStop(0, flash > 0.72 ? "#ffe36c" : "#6c1516"); + bodyGrad.addColorStop(0.28, "#d52c25"); + bodyGrad.addColorStop(0.56, "#f2522d"); + bodyGrad.addColorStop(1, "#7f1817"); + } else { + bodyGrad.addColorStop(0, flash > 0.72 ? "#ffef91" : "#5e221f"); + bodyGrad.addColorStop(0.28, "#a73a30"); + bodyGrad.addColorStop(0.56, "#c74b3c"); + bodyGrad.addColorStop(1, "#67241f"); + } + ctx.fillStyle = bodyGrad; + ctx.strokeStyle = flameMode ? "rgba(80,16,14,0.96)" : "rgba(66,25,22,0.96)"; ctx.lineWidth = 2.6; - ctx.font = `bold ${Math.max(10, this.r * 0.72)}px ui-rounded, sans-serif`; + roundedRect(ctx, -this.r * 0.66, -this.r * 0.88, this.r * 1.32, this.r * 1.76, this.r * 0.16); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = flameMode ? "#7f1717" : "#6d2822"; + for (const sy of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(-this.r * 0.58, sy * this.r * 0.80); + for (let i = 0; i <= 6; i++) { + const xx = -this.r * 0.58 + i * this.r * 0.193; + const yy = sy * this.r * (0.80 + (i % 2 ? 0.11 : 0.02)); + ctx.lineTo(xx, yy); + } + ctx.lineTo(this.r * 0.58, sy * this.r * 0.80); + ctx.closePath(); ctx.fill(); + } + ctx.fillStyle = flameMode ? "rgba(255,190,48,0.94)" : "rgba(245,222,180,0.90)"; + roundedRect(ctx, -this.r * 0.52, -this.r * 0.38, this.r * 1.04, this.r * 0.76, this.r * 0.08); + ctx.fill(); + if (flameMode) { + ctx.fillStyle = "#d43a22"; + ctx.beginPath(); + ctx.moveTo(0, this.r * 0.24); + ctx.bezierCurveTo(-this.r * 0.24, this.r * 0.03, -this.r * 0.08, -this.r * 0.19, 0, -this.r * 0.29); + ctx.bezierCurveTo(this.r * 0.28, -this.r * 0.06, this.r * 0.25, this.r * 0.12, 0, this.r * 0.24); + ctx.fill(); + } else { + ctx.strokeStyle = "#8a342b"; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(-this.r * 0.28, 0); ctx.lineTo(this.r * 0.28, 0); ctx.moveTo(0, -this.r * 0.22); ctx.lineTo(0, this.r * 0.22); ctx.stroke(); + } + ctx.strokeStyle = "rgba(63,52,37,0.98)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(this.r * 0.10, -this.r * 0.90); + ctx.bezierCurveTo(this.r * 0.30, -this.r * 1.18, this.r * 0.58, -this.r * 1.34, this.r * 0.88, -this.r * 1.25); + ctx.stroke(); + ctx.strokeStyle = "rgba(196,151,73,0.82)"; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(this.r * 0.13, -this.r * 0.92); ctx.bezierCurveTo(this.r * 0.34, -this.r * 1.16, this.r * 0.60, -this.r * 1.27, this.r * 0.86, -this.r * 1.23); ctx.stroke(); + ctx.fillStyle = flash > 0.1 ? "rgba(255,235,104,0.98)" : "rgba(240,104,46,0.88)"; + ctx.shadowColor = flash > 0.1 ? "rgba(255,186,44,0.85)" : "transparent"; + ctx.shadowBlur = flash > 0.1 ? 8 : 0; + ctx.beginPath(); ctx.arc(this.r * 0.90, -this.r * 1.24, 3 + flash * 2.3, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = "rgba(255,250,230,0.98)"; + ctx.strokeStyle = flameMode ? "rgba(92,20,15,0.98)" : "rgba(65,25,22,0.98)"; + ctx.lineWidth = 3; + ctx.font = `900 ${Math.max(11, this.r * 0.74)}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.strokeText(timerText, 0, this.r * 0.02); ctx.fillText(timerText, 0, this.r * 0.02); + ctx.restore(); } else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "glass_wall" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence" || this.type === "one_way_fence") { const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v"; const gate = this.type === "gate_fence"; @@ -1202,9 +1591,9 @@ draw(ctx, t, lighting = null) { const open = gate && this.gateOpen; ctx.save(); ctx.rotate(angle); - const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : oneWay ? (styleNight ? "#6b5a86" : "#9b78c8") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31")); - const edge = bounce ? (styleNight ? "#243755" : "#286093") : oneWay ? (styleNight ? "#352943" : "#593a80") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25"); - const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : oneWay ? (styleNight ? "rgba(232,220,255,0.25)" : "rgba(255,245,255,0.55)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)"); + const wood = bounce ? (styleNight ? "#9b4f76" : "#ef76aa") : oneWay ? (styleNight ? "#6b5a86" : "#9b78c8") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31")); + const edge = bounce ? (styleNight ? "#52253d" : "#a93f70") : oneWay ? (styleNight ? "#352943" : "#593a80") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25"); + const hi = bounce ? (styleNight ? "rgba(255,213,235,0.24)" : "rgba(255,238,248,0.58)") : oneWay ? (styleNight ? "rgba(232,220,255,0.25)" : "rgba(255,245,255,0.55)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)"); const drawRail = (x0, y0, w, h, alpha = 1) => { ctx.save(); ctx.globalAlpha *= alpha; diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index 9a4850b..618a371 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -20,6 +20,10 @@ const TOOL_DEFINITIONS = Object.freeze({ wire: { id: "wire", label: "\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u89e6\u308b\u3068\u611f\u96fb\u3002" }, insulated_wire: { id: "insulated_wire", label: "\u7d76\u7e01\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u611f\u96fb\u3057\u306a\u3044\u3002" }, shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" }, + oil: { id: "oil", itemType: "oil", label: "\u6cb9", placeable: true, scalable: true, radius: 58, amount: 100, iconText: "\u6cb9", tooltip: "\u6469\u64e6\u3092\u5927\u304d\u304f\u4e0b\u3052\u308b\u3002\u706b\u3084\u7206\u767a\u3067\u5f15\u706b\u3057\u3001\u71c3\u3048\u5c3d\u304d\u308b\u3068\u6d88\u3048\u308b\u3002" }, + bear_trap: { id: "bear_trap", itemType: "bear_trap", label: "\u30c8\u30e9\u30d0\u30b5\u30df", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u7f60", tooltip: "\u52d5\u304f\u3082\u306e\u3092\u6355\u3089\u3048\u3066\u3057\u3070\u3089\u304f\u96e2\u3055\u306a\u3044\u3002" }, + leakage_device: { id: "leakage_device", itemType: "leakage_device", label: "\u6f0f\u96fb\u5668", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u6f0f", tooltip: "\u901a\u96fb\u4e2d\u3001\u5e83\u7bc4\u56f2\u306b\u96fb\u6483\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, + poison_gas_can: { id: "poison_gas_can", itemType: "poison_gas_can", label: "\u6bd2\u30ac\u30b9\u7f36", placeable: true, scalable: true, radius: 28, amount: 999, iconText: "\u6bd2", tooltip: "\u6bd2\u30ac\u30b9\u3092\u653e\u51fa\u3059\u308b\u3002" }, robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, @@ -78,6 +82,7 @@ const TOOL_DEFINITIONS = Object.freeze({ ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, toilet: { id: "toilet", itemType: "toilet", label: "\u30c8\u30a4\u30ec\u306e\u7802", placeable: true, scalable: true, radius: 48, amount: 999, iconText: "\u25a3", tooltip: "\u4e0a\u306b\u6392\u6cc4\u3055\u308c\u305f\u305a\u3093\u3061\u304c\u76f4\u3061\u306b\u6d88\u6ec5\u3059\u308b\u3002" }, rain_shelter: { id: "rain_shelter", itemType: "rain_shelter", label: "\u30d1\u30e9\u30bd\u30eb", placeable: true, scalable: true, radius: 50, amount: 999, iconText: "\u26f1", tooltip: "\u96e8\u3068\u65e5\u5dee\u3057\u3092\u9632\u304e\u3001\u4f53\u611f\u6c17\u6e29\u3092\u7a4f\u3084\u304b\u306b\u3059\u308b\u3002" }, + bell: { id: "bell", label: "呼び鈴", placeable: false, scalable: true, iconText: "🔔", tooltip: "周りのたりないを集める" }, first_aid: { id: "first_aid", itemType: "first_aid", label: "\u6551\u6025\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u271a", tooltip: "\u98df\u3079\u308b\u3068HP\u3092\u5373\u6642\u56de\u5fa9\u3059\u308b\u3002" }, sedative: { id: "sedative", itemType: "sedative", label: "\u93ae\u9759\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u25cc", tooltip: "\u98df\u3079\u308b\u3068\u6050\u6016\u3084\u3051\u3093\u304b\u6c17\u5206\u3092\u843d\u3061\u7740\u304b\u305b\u308b\u3002" }, }); diff --git a/js/item_tool_metadata.js b/js/item_tool_metadata.js index 9665b0d..454ca42 100644 --- a/js/item_tool_metadata.js +++ b/js/item_tool_metadata.js @@ -10,13 +10,13 @@ kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]), freeStackingMovable: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi", "water", "zunda_juice", "mercury", "genkotsu", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "ant_corpse"]), sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]), - draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "seesaw", "circuit_board"]), + draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "oil", "bear_trap", "leakage_device", "poison_gas_can", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "seesaw", "circuit_board"]), }); const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence"])); const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); - const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]); + const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board", "leakage_device"]); const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h", "circuit_board"]); const PHYSICS_CATEGORY_TOOL_IDS = Object.freeze(PHYSICS_TOOL_IDS.filter(id => !ELECTRIC_TOOL_IDS.includes(id))); const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan", "circuit_board"])); @@ -27,13 +27,13 @@ }); const TOOL_CATEGORIES = Object.freeze([ - { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] }, + { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "clear_tool", "water_hose", "bell"] }, { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] }, { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] }, { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "toilet", "rain_shelter", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "seesaw", "signboard"] }, { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_CATEGORY_TOOL_IDS }, { id: "electric", label: "\u96fb\u6c17", themeClass: "tool-category-electric", order: 46, toolIds: ELECTRIC_TOOL_IDS }, - { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] }, + { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "oil", "bear_trap", "poison_gas_can", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 703e487..909c7b1 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -41,7 +41,7 @@ function initializeItemTypeState(item, type, x, y) { if (type === "water" || type === "zunda_juice") item.roles.drink = true; if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true; - if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true; + if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "splat", "oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") { item.grassStage = Math.floor(rand(0, 2.999)); @@ -305,6 +305,12 @@ function initializeItemTypeState(item, type, x, y) { item.amount = 999; } + if (type === "oil") { item.r = 58; item.amount = 100; item.oilBurning = false; item.oilBurnTimer = 0; item.nextOilFireAt = 0; item.deletable = true; } + if (type === "bear_trap") { item.r = 34; item.amount = 999; item.trapTargetId = ""; item.trapTargetRef = null; item.trapTriggeredAt = 0; item.trapUntil = 0; item.trapClosed = false; item.trapDamageableTarget = false; item.trapHoldDuration = 0; item.nextTrapDamageAt = 0; item.deletable = true; } + if (type === "leakage_device") { item.r = 34; item.amount = 999; item.nextLeakagePulseAt = 0; item.deletable = true; } + if (type === "poison_gas_can") { item.r = 28; item.amount = 999; item.fuseMax = 5; item.fuseTimer = 5; item._gasReleased = false; item.deletable = true; } + if (type === "poison_gas") { item.r = 96; item.amount = 100; item.gasConcentration = 1; item.gasAge = 0; item.gasLife = 22; item.gasExpansion = 72; item.vx = 0; item.vy = 0; item.deletable = false; } + if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) { globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized"); } diff --git a/js/item_update_policy.js b/js/item_update_policy.js index 75c2a9b..6c1f484 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -5,10 +5,10 @@ // helpers and the item scheduler both read this single policy instead of // duplicating item-type timing rules. (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]); + const REALTIME_ITEM_TYPES = new Set(["oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas", "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const SCHEDULED_ITEM_TYPES = Object.freeze([ - "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo", + "oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas", "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo", "plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "bed", "nest_box", "pipe", "toilet", "rain_shelter", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", @@ -38,9 +38,11 @@ Number(item.lastShotAt || -999) || -999 ); if (now - lastActiveAt < 0.35) return true; - // Fan wind is a continuous force. Scheduling it as an idle item turns the - // accumulated dt into periodic gusts, so fans must remain in the realtime lane. - if (item.type === "fan") return true; + // Fan wind and bear-trap contact are continuous interactions. Scheduling + // them as idle items can turn movement/contact into periodic samples and + // let a moving object pass completely through between checks. + if (item.type === "fan" || item.type === "bear_trap") return true; + if (item.type === "poison_gas_can") return Number(item.fuseTimer ?? 5) > 0; if (item.type === "firecracker" || item.type === "flame_firecracker" || item.type === "sticky_bomb") return Boolean(item.fuseTimer > 0 || item.armed || item.active); if (item.type === "robot_cleaner") return Boolean(item.active || item.cleanerActive || item.targetId || item.targetRef || item.state === "clean" || item.state === "return"); if (PIN_ITEM_TYPES.has(item.type)) return Boolean(item.pinState && item.pinState !== "idle" && item.pinState !== "ground"); @@ -50,6 +52,7 @@ function idleRealtimeInterval(item) { if (!item) return Infinity; + if (item.type === "oil" || item.type === "bear_trap" || item.type === "leakage_device" || item.type === "poison_gas_can" || item.type === "poison_gas") return 0.18; if (item.type === "ball" || item.type === "balloon") return 0.24; if (item.type === "fan") return 0.60; if (item.type === "genkotsu") return 0.45; @@ -89,8 +92,8 @@ if (item.type === "grass") return Infinity; if (item.type === "duplicator") return adjustedInterval(0.5); if (item.type === "water") return adjustedInterval(1.8); - if (item.type === "trace" || item.type === "splat") return adjustedInterval(1.2); - if (item.type === "ant_corpse") return adjustedInterval(3.5); + if (item.type === "trace" || item.type === "splat") return adjustedInterval(2.8); + if (item.type === "ant_corpse") return adjustedInterval(5.5); if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return adjustedInterval(5.0); if (isServingFoodType(item.type)) return adjustedInterval(5.0); return Infinity; diff --git a/js/item_update_scheduler.js b/js/item_update_scheduler.js index fb9dae0..8b2a404 100644 --- a/js/item_update_scheduler.js +++ b/js/item_update_scheduler.js @@ -108,6 +108,20 @@ return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().scheduledItemBudget || 30)); } + function beginTouchedFrame(worldRef, state) { + if (!state.touchedFrame) state.touchedFrame = []; + else state.touchedFrame.length = 0; + if (!state.touchedSet) state.touchedSet = new Set(); + else state.touchedSet.clear(); + worldRef._itemUpdateTouched = state.touchedFrame; + } + + function recordTouched(state, item) { + if (!item || item.dead || state.touchedSet.has(item)) return; + state.touchedSet.add(item); + state.touchedFrame.push(item); + } + function runRealtime(worldRef, state, dt) { let ran = 0; const now = worldRef.time || 0; @@ -123,6 +137,7 @@ } if (interval <= 0) { global.TarinaiItemRuntime.updateOne(item, dt, worldRef); + recordTouched(state, item); item._schedulerLastAt = now; ran += 1; const nextInterval = itemUpdateInterval(item); @@ -155,6 +170,7 @@ } const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval))); global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef); + recordTouched(state, item); item._schedulerLastAt = now; ran += 1; dueRuns += 1; @@ -197,6 +213,7 @@ const end = global.TarinaiPerf.begin("update.items"); try { const state = ensure(worldRef); + beginTouchedFrame(worldRef, state); const realtimeEnd = global.TarinaiPerf.begin("update.items.realtime"); let realtimeRan = runRealtime(worldRef, state, dt); let ran = realtimeRan; @@ -207,9 +224,11 @@ ran += dueRan; if (dueEnd) dueEnd(); global.TarinaiSignalSystem?.updateWorld?.(worldRef, dt); + global.TarinaiItemEnvironmentHazardSystem?.updatePoweredHazards?.(worldRef, dt); const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld"); const physicsBudget = physicsBudgetFor(worldRef); const physicsWorld = global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget); + const trapHeld = global.TarinaiItemEnvironmentHazardSystem?.enforceTrapHolds?.(worldRef) || 0; const mechanicalWorld = physicsWorld.mechanical || null; const constraintWorld = physicsWorld.constraintRan || 0; const postConstraintPairs = physicsWorld.postConstraintPairs || 0; @@ -232,6 +251,7 @@ physicsBudget, poisonContactBudget, poisonContactRemoved, + trapHeld, physics: physicsWorld || mechanicalWorld, constraints: constraintWorld, postConstraintPairs, @@ -245,7 +265,7 @@ } function trackedDynamicItems(worldRef) { - return worldRef?._itemUpdateRealtime || []; + return worldRef?._itemUpdateTouched || []; } global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems, wakeItem }); diff --git a/js/main.js b/js/main.js index 6a967df..9f604aa 100644 --- a/js/main.js +++ b/js/main.js @@ -100,6 +100,7 @@ function loop() { window.TarinaiPerf.beginFrame(); const endUpdate = window.TarinaiPerf.begin("update.total"); world.update(dt); + if (typeof tickColonyHistorySampling === "function") tickColonyHistorySampling(); if (endUpdate) endUpdate(); const endRender = window.TarinaiPerf.begin("render.total"); render(); @@ -146,3 +147,14 @@ if ("serviceWorker" in navigator) { navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); }); } + + +// Expose the active build on the root element so stale-cache reports can be verified immediately. +(function markActiveBuild() { + const apply = () => { + if (!document?.documentElement) return; + document.documentElement.dataset.tarinaiBuild = String(window.TARINAI_APP?.build || window.TARINAI_VERSION || "unknown"); + }; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", apply, { once: true }); + else apply(); +})(); diff --git a/js/mechanical_system.js b/js/mechanical_system.js index 3301ad6..641beb6 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -636,6 +636,78 @@ return applied; } + function applyExternalImpulse(item, contactX, contactY, fx, fy, worldRef = null, scale = 1) { + if (!item || item.dead || !isMechanicalType(item)) return false; + const sx = num(fx) * num(scale, 1); + const sy = num(fy) * num(scale, 1); + let changed = applyImpulse(item, contactX, contactY, sx, sy, item.type === "rotator" ? 1.05 : 0.72); + if (item.type === "rotator" || item.type === "reciprocator") { + const translationGain = item.type === "rotator" ? 0.28 : 0.18; + item.externalVx = clamp(num(item.externalVx) + sx * translationGain, -560, 560); + item.externalVy = clamp(num(item.externalVy) + sy * translationGain, -560, 560); + wakeItem(item, "external-impulse"); + changed = true; + } + if (changed) { + worldRef?.markSpatialDirty?.("mechanical-external-impulse"); + if (worldRef) worldRef.drawListDirty = true; + } + return changed; + } + + function integrateExternalTranslation(item, dt = 0.016, worldRef = null) { + if (!item || item.dead || !isMechanicalType(item) || (item.type !== "rotator" && item.type !== "reciprocator")) return false; + const step = Math.max(0.001, Math.min(0.05, num(dt, 0.016))); + let vx = num(item.externalVx); + let vy = num(item.externalVy); + if (Math.hypot(vx, vy) < 0.05) { item.externalVx = 0; item.externalVy = 0; return false; } + const dx = vx * step; + const dy = vy * step; + if (item.type === "reciprocator") { + resetAnchor(item); + const rail = bodyRail(item); + if (rail) { + rail.anchorX = num(rail.anchorX, item.x) + dx; + rail.anchorY = num(rail.anchorY, item.y) + dy; + pset(item, "railAnchorX", rail.anchorX, "external-translation"); + pset(item, "railAnchorY", rail.anchorY, "external-translation"); + } + const pos = positionFromPhase(item); + item.x = pos.x; + item.y = pos.y; + } else { + item.x = num(item.x) + dx; + 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 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); + item.y = clamp(item.y, rr, num(worldRef.h, 800) - rr); + if (item.type === "reciprocator") { + const rail = bodyRail(item); + const pos = positionFromPhase(item); + const fixX = item.x - pos.x; + const fixY = item.y - pos.y; + if (rail && (Math.abs(fixX) > 0.001 || Math.abs(fixY) > 0.001)) { + rail.anchorX = num(rail.anchorX) + fixX; + rail.anchorY = num(rail.anchorY) + fixY; + pset(item, "railAnchorX", rail.anchorX, "external-boundary"); + pset(item, "railAnchorY", rail.anchorY, "external-boundary"); + } + } + } + const damp = Math.pow(0.12, step); + item.externalVx = vx * damp; + item.externalVy = vy * damp; + if (Math.abs(item.externalVx) < 0.05) item.externalVx = 0; + if (Math.abs(item.externalVy) < 0.05) item.externalVy = 0; + invalidateGeometry(item); + commitBody(item); + return true; + } + function applyRailCorrection(item, nx, ny, overlap, scale = 1) { if (!item || item.dead || item.type !== "reciprocator" || item.playerHeld || item._heldByPlayer) return false; resetAnchor(item); @@ -752,8 +824,31 @@ } + function isPassiveDriveBlocker(other) { + if (!other || other.dead) return false; + if (other.type !== "rotator" && other.type !== "reciprocator") return false; + return !isPowered(other); + } + + function hasPassiveDriveContactAhead(item, worldRef) { + if (!item || item.dead || item.type !== "reciprocator" || !worldRef) return false; + const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, reach(item) + 110, true) || worldRef.items || []; + const a = axis(item); + const dir = Math.sign(ps(item, "railDir", 1)) || 1; + const driveX = a.x * dir, driveY = a.y * dir; + for (const other of source) { + if (!other || other === item || !isPassiveDriveBlocker(other)) continue; + const contact = strongestRectContact(obstacleRects(item), obstacleRects(other), 0.85); + if (!contact) continue; + const ahead = driveX * num(contact.info.nx) + driveY * num(contact.info.ny); + if (ahead >= 0.12) return true; + } + return false; + } + function noteReciprocatorBlocked(item, dt = 0.016, worldRef = null, reason = "obstacle") { if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false; + if (reason === "rail-stall-blocked" && hasPassiveDriveContactAhead(item, worldRef)) return false; const now = Number(worldRef?.time || item.world?.time || 0) || 0; const step = Math.max(0.012, Math.min(0.08, num(dt, 0.016))); if ((item._reciprocatorBlockedAt || -999) + 0.18 < now) item._reciprocatorBlockedTimer = 0; @@ -998,8 +1093,8 @@ let info = contact.info; // Reverse from the initial impact. Separation can fully clear the overlap, // but that still represents a real collision and must not skip reversal. - if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical"); - if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical"); + if (a.type === "reciprocator" && !isPassiveDriveBlocker(b)) reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical"); + if (b.type === "reciprocator" && !isPassiveDriveBlocker(a)) reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical"); const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef); if (separated) { // Position projection may fully clear the overlap. Re-query before adding @@ -1117,6 +1212,7 @@ return runMechanicalStep(item, () => { item.amount = 999; if (item.playerHeld || item._heldByPlayer) return false; + const translated = integrateExternalTranslation(item, dt, worldRef); const powered = isPowered(item); const passiveSpeed = ps(item, "spin", 0); const speed = (powered ? ps(item, "motorSpeed", 0) : 0) + passiveSpeed; @@ -1125,7 +1221,7 @@ const ext = extent(item); item.r = Math.max(item.r || 64, Math.min(460, ext)); const skipInteractions = opts.skipInteractions === true || opts.centralStep === true; - if (!speed || !Number.isFinite(speed)) return (!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false; + if (!speed || !Number.isFinite(speed)) return translated || ((!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false); const totalDelta = speed * dt; if (skipInteractions || !hasInteractionCandidates(item, worldRef, ext + 100)) { item.prevAngle = num(item.angle); @@ -1223,6 +1319,7 @@ item.amount = 999; resetAnchor(item); if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; } + const translated = integrateExternalTranslation(item, dt, worldRef); const powered = isPowered(item); const prevX = num(item.x, ps(item, "railAnchorX", 0)), prevY = num(item.y, ps(item, "railAnchorY", 0)); item.prevX = prevX; item.prevY = prevY; @@ -1259,7 +1356,7 @@ if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); } return true; } - return false; + return translated; }); } @@ -1865,6 +1962,7 @@ updateReciprocator, updatePoisonBlock, applyPokeImpulse, + applyExternalImpulse, hitTest, invalidateGeometry, wakeItem, diff --git a/js/memorial_bell_system.js b/js/memorial_bell_system.js new file mode 100644 index 0000000..38ce34b --- /dev/null +++ b/js/memorial_bell_system.js @@ -0,0 +1,629 @@ +"use strict"; + +(function (global) { + const BUILD_TEXT = "親しいたりないの墓を作っている。"; + const MOURN_TEXT = "親しいたりないの墓が壊されて泣いている。"; + const BELL_TEXT = "呼び鈴の鳴った場所に集まっている。"; + const GRAVE_VISIT_APPROACH_TEXT = "親しいたりないの墓参りに向かっている。"; + const GRAVE_VISIT_TEXT = "親しいたりないの墓参りをしている。"; + const BELL_RANGES = Object.freeze({ small: 120, medium: 210, large: 350 }); + const BELL_DURATIONS = Object.freeze({ small: 12, medium: 18, large: 26 }); + + function familyKeyOf(t) { + return String(t?.familyKey || t?.id || ""); + } + + function ensureRequests(worldRef) { + if (!worldRef) return []; + if (!Array.isArray(worldRef.memorialRequests)) worldRef.memorialRequests = []; + return worldRef.memorialRequests; + } + + function requestById(worldRef, id) { + return ensureRequests(worldRef).find(req => req && req.id === id) || null; + } + + function liveByFamilyKey(worldRef, key) { + if (!worldRef || !key) return null; + return (worldRef.tarinai || []).find(t => t && !t.dead && familyKeyOf(t) === key) || null; + } + + function activeGraveFor(worldRef, deceasedFamilyKey) { + if (!worldRef || !deceasedFamilyKey) return null; + return (worldRef.items || []).find(item => item && !item.dead && item.type === "grave" && item.memorialFamilyKey === deceasedFamilyKey) || null; + } + + function relatedGravesFor(worldRef, tarinai) { + if (!worldRef || !tarinai) return []; + const key = familyKeyOf(tarinai); + if (!key) return []; + return (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave" + && Array.isArray(item.memorialEligibleFamilyKeys) && item.memorialEligibleFamilyKeys.includes(key)); + } + + function graveById(worldRef, id) { + if (!worldRef || !id) return null; + const item = worldRef.itemById?.(id) || (worldRef.items || []).find(entry => entry?.id === id) || null; + return item && !item.dead && item.type === "grave" ? item : null; + } + + function calmEnoughForGraveVisit(t) { + if (!t || t.dead || isCritical(t)) return false; + if (!["idle", "wander"].includes(String(t.state || "idle"))) return false; + if ((t.hunger || 0) >= 62 || (t.energy || 100) <= 38 || (t.stress || 0) >= 72) return false; + const needValues = ["food", "sleep", "health", "safety", "social", "fulfill"] + .map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0); + return Math.max(0, ...needValues) < 58; + } + + function clearGraveVisit(t, reason = "") { + if (!t) return; + const wasVisiting = t.state === "visit_grave"; + t.graveVisitTargetId = ""; + t.graveVisitUntil = 0; + t.graveVisitPhase = ""; + t.graveVisitReliefApplied = false; + t.nextGraveVisitCheckAt = Math.max(Number(t.nextGraveVisitCheckAt || 0), Number(t.world?.time || 0) + 28); + if (wasVisiting) { + t.target = null; + t.goIdle?.(reason || "墓参りを終えた。"); + } + } + + function graveVisitHasUrgentNeed(t) { + if (!t) return true; + if ((t.hunger || 0) >= 78 || (t.energy || 100) <= 24 || (t.stress || 0) >= 84) return true; + const needValues = ["food", "sleep", "health", "safety"] + .map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0); + return Math.max(0, ...needValues) >= 76; + } + + function maintainGraveVisit(t) { + if (!t || !t.graveVisitTargetId) return false; + if (t.state !== "visit_grave") { clearGraveVisit(t); return false; } + const worldRef = t.world; + const grave = graveById(worldRef, t.graveVisitTargetId); + if (!grave || isCritical(t) || graveVisitHasUrgentNeed(t)) { clearGraveVisit(t, grave ? "別の用事を優先した。" : "墓がなくなった。"); return false; } + const now = Number(worldRef?.time || 0) || 0; + if (t.graveVisitPhase === "visiting") { + if (now >= Number(t.graveVisitUntil || 0)) { clearGraveVisit(t, "墓参りを終えた。"); return false; } + const mournText = `${grave.memorialName || "親しいたりない"}を悼んでいる。`; + setSpecialBehavior(t, "visit_grave", mournText, grave); + return true; + } + const approachText = `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`; + setSpecialBehavior(t, "visit_grave", approachText, grave); + return true; + } + + function maybeStartGraveVisit(t) { + const worldRef = t?.world; + if (!worldRef || !calmEnoughForGraveVisit(t)) return false; + const now = Number(worldRef.time || 0) || 0; + if (now < Number(t.nextGraveVisitCheckAt || 0)) return false; + t.nextGraveVisitCheckAt = now + 10; + const chance = 0.045; // checked at most once per 10 seconds while otherwise idle + const hit = typeof global.deterministicChance === "function" + ? global.deterministicChance(worldRef, "idle-grave-visit", chance, t, Math.floor(now / 10)) + : Math.random() < chance; + if (!hit) return false; + const graves = relatedGravesFor(worldRef, t); + if (!graves.length) return false; + graves.sort((a, b) => Math.hypot(a.x - t.x, a.y - t.y) - Math.hypot(b.x - t.x, b.y - t.y)); + const shortlist = graves.slice(0, Math.min(3, graves.length)); + const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-${Math.floor(now / 10)}`) : Math.random(); + const grave = shortlist[Math.min(shortlist.length - 1, Math.floor(unit * shortlist.length))] || shortlist[0]; + if (!grave) return false; + t.graveVisitTargetId = grave.id || ""; + t.graveVisitPhase = "approach"; + t.graveVisitUntil = 0; + t.graveVisitReliefApplied = false; + setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`, grave); + return true; + } + + function relationAffinity(a, b) { + if (!a || !b) return -Infinity; + const aToB = Number(a.relationships?.[b.id]?.affinity); + const bToA = Number(b.relationships?.[a.id]?.affinity); + return Math.max(Number.isFinite(aToB) ? aToB : -Infinity, Number.isFinite(bToA) ? bToA : -Infinity); + } + + function eligibleFamilyKeysAtDeath(worldRef, dead) { + const related = new Set([...(dead?.parents || []), ...(dead?.children || [])].filter(Boolean)); + const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; + for (const other of worldRef?.tarinai || []) { + if (!other || other.dead || other === dead) continue; + const key = familyKeyOf(other); + if (!key) continue; + if (related.has(key) || relationAffinity(dead, other) >= threshold) related.add(key); + } + related.delete(familyKeyOf(dead)); + return Array.from(related).filter(key => liveByFamilyKey(worldRef, key)); + } + + const MEMORIAL_BUILD_CHANCE = 0.35; + const GRAVE_BREAK_FORCE = 720; + + function createGraveFromRequest(worldRef, req, builderFamilyKey = "", reason = "memorial-built") { + if (!worldRef || !req || activeGraveFor(worldRef, req.deceasedFamilyKey)) return activeGraveFor(worldRef, req.deceasedFamilyKey); + const grave = global.StructureRegistry?.create?.("grave", null, req.x, req.y, worldRef) || null; + if (!grave) return null; + grave.memorialFamilyKey = req.deceasedFamilyKey; + grave.memorialName = req.deceasedName; + grave.memorialType = req.deceasedType; + grave.memorialDeathReason = req.deathReason; + grave.memorialDeathTime = req.deathTime; + grave.memorialBuilderFamilyKey = String(builderFamilyKey || ""); + grave.memorialEligibleFamilyKeys = Array.from(new Set(req.eligibleFamilyKeys || [])); + grave.memorialFavorite = Boolean(req.deceasedFavorite); + grave.memorialTarinaiKing = Boolean(req.deceasedTarinaiKing); + grave.memorialZunchiSlave = Boolean(req.deceasedZunchiSlave); + grave.createdAt = Number(worldRef.time || 0) || 0; + grave._naturalDecayLastWorldTime = grave.createdAt; + const added = worldRef.addItem?.(grave, reason) || null; + if (!added) return null; + worldRef.markItemBucketsDirty?.(reason); + worldRef.markSpatialDirty?.(reason); + worldRef.markTerrainDirtyAt?.(grave.x, grave.y, 52, reason); + worldRef.drawListDirty = true; + return grave; + } + + function captureDeath(worldRef, dead, reason = "") { + if (!worldRef || !dead) return null; + const deceasedFamilyKey = familyKeyOf(dead); + if (!deceasedFamilyKey) return null; + const requests = ensureRequests(worldRef); + const existing = requests.find(req => req?.deceasedFamilyKey === deceasedFamilyKey && req.status !== "cancelled"); + if (existing) return existing; + const eligibleFamilyKeys = eligibleFamilyKeysAtDeath(worldRef, dead); + const deceasedFavorite = Boolean(dead.favorite); + if (!deceasedFavorite && !eligibleFamilyKeys.length) return null; + if (!deceasedFavorite) { + const chanceHit = typeof global.deterministicChance === "function" + ? global.deterministicChance(worldRef, "memorial-build-at-death", MEMORIAL_BUILD_CHANCE, dead, deceasedFamilyKey) + : Math.random() < MEMORIAL_BUILD_CHANCE; + if (!chanceHit) return null; + } + const req = { + id: `memorial-${deceasedFamilyKey}-${Math.round(Number(worldRef.time || 0) * 10)}`, + deceasedFamilyKey, + deceasedName: String(dead.name || "たりない"), + deceasedType: String(dead.type || "smile"), + deathReason: String(reason || dead.deathReason || ""), + deathTime: dead.deathTime != null && Number.isFinite(Number(dead.deathTime)) ? Number(dead.deathTime) : Number(worldRef.time || 0), + deceasedFavorite, + deceasedTarinaiKing: Boolean(dead.isTarinaiChampion), + deceasedZunchiSlave: Boolean(dead.isZunchiSlave), + x: Number(dead.x || 0) || 0, + y: Number(dead.y || 0) || 0, + eligibleFamilyKeys, + builderFamilyKey: "", + splatId: "", + status: deceasedFavorite ? "building_direct" : "pending", + createdAt: Number(worldRef.time || 0) || 0, + }; + requests.push(req); + if (deceasedFavorite) { + const grave = createGraveFromRequest(worldRef, req, "", "favorite-memorial-direct"); + if (grave) { + req.status = "complete"; + worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 44, life: 0.48, color: "rgba(171,154,118,0.44)" }); + } else req.status = "cancelled"; + } + return req; + } + + function liveSplatForRequest(worldRef, req) { + if (!worldRef || !req) return null; + let best = null; + let bestD = Infinity; + for (const item of worldRef.residuesOfType?.("splat") || []) { + if (!item || item.dead || Number(item.amount || 0) <= 0) continue; + if (item._memorialReservedBy && item._memorialReservedBy !== req.id) continue; + const d = Math.hypot((Number(item.x || 0) || 0) - req.x, (Number(item.y || 0) || 0) - req.y); + if (d <= 190 && d < bestD) { best = item; bestD = d; } + } + return best; + } + + function isCritical(t) { + if (!t || t.dead) return true; + if (t.burning || (t.burnTimer || 0) > 0.02 || t.sleepDisease || t.fightDisease) return true; + if ((t.fightTimer || 0) > 0.04 || (t.birthRitualTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04) return true; + return ["panic", "fight", "birth_ritual", "intimidate", "ant_attack", "frozen"].includes(String(t.state || "")); + } + + function setSpecialBehavior(t, state, text, target = null) { + t.setActionState?.(state, { target, reason: text, wake: true, sleeping: false }); + t.thought = text; + t.target = target; + if (typeof global.setBehaviorText === "function") { + global.setBehaviorText(t, { + need: (state === "mourn_grave" || state === "visit_grave") ? "social" : "fulfill", + actionId: state, + actionLabel: text, + reasonText: text, + target, + phase: "perform", + source: "behavior", + }); + } + } + + function clearBuilderAssignment(t, req = null) { + if (req && req.builderFamilyKey === familyKeyOf(t)) req.builderFamilyKey = ""; + const worldRef = t?.world; + const splat = req?.splatId ? worldRef?.residueByRuntimeId?.(req.splatId) : null; + if (splat && splat._memorialReservedBy === req?.id) splat._memorialReservedBy = ""; + if (t) { + t.memorialRequestId = ""; + t.memorialBuildProgress = 0; + if (t.state === "build_memorial") t.goIdle?.("墓作りをやめた"); + } + } + + function assignMemorial(t) { + const worldRef = t?.world; + if (!worldRef || !t || isCritical(t)) return false; + const key = familyKeyOf(t); + if (!key) return false; + for (const req of ensureRequests(worldRef)) { + if (!req || req.status !== "pending" || !req.eligibleFamilyKeys?.includes?.(key)) continue; + if (activeGraveFor(worldRef, req.deceasedFamilyKey)) { req.status = "complete"; continue; } + if (req.builderFamilyKey) { + const builder = liveByFamilyKey(worldRef, req.builderFamilyKey); + if (builder && builder.memorialRequestId === req.id) continue; + req.builderFamilyKey = ""; + } + const splat = liveSplatForRequest(worldRef, req); + if (!splat) continue; + req.builderFamilyKey = key; + req.splatId = splat.id || ""; + splat._memorialReservedBy = req.id; + t.memorialRequestId = req.id; + t.memorialBuildProgress = 0; + setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat); + return true; + } + return false; + } + + function completeMemorial(t, req, splat) { + const worldRef = t?.world; + if (!worldRef || !req || !splat) return false; + const grave = createGraveFromRequest(worldRef, req, familyKeyOf(t), "memorial-built"); + if (!grave) return false; + splat.amount = 0; + splat.hp = 0; + splat._memorialReservedBy = ""; + worldRef.removeResidue?.(splat, "memorial-material-consumed"); + req.status = "complete"; + req.builderFamilyKey = ""; + req.splatId = ""; + t.memorialRequestId = ""; + t.memorialBuildProgress = 0; + t.target = null; + if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -48, fulfill: -8 }); + t.goIdle?.("親しいたりないの墓を作った。"); + worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 38, life: 0.42, color: "rgba(128,120,112,0.42)" }); + return true; + } + + function updateMemorialMotion(t, dt) { + const worldRef = t?.world; + const req = requestById(worldRef, t?.memorialRequestId); + if (!req || req.status !== "pending" || req.builderFamilyKey !== familyKeyOf(t)) { + if (t?.memorialRequestId) clearBuilderAssignment(t, req); + return false; + } + if (isCritical(t) && t.state !== "build_memorial") return false; + let splat = req.splatId ? worldRef.residueByRuntimeId?.(req.splatId) : null; + if (!splat || splat.dead || splat.type !== "splat" || Number(splat.amount || 0) <= 0) { + splat = liveSplatForRequest(worldRef, req); + if (!splat) { clearBuilderAssignment(t, req); return false; } + req.splatId = splat.id || ""; + splat._memorialReservedBy = req.id; + } + setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat); + const d = Math.hypot((splat.x || 0) - t.x, (splat.y || 0) - t.y); + const reach = Math.max(28, (t.radius || 22) + (splat.r || 20) * 0.55); + if (d > reach) return false; + t.vx *= Math.pow(0.18, Math.max(0.016, dt) * 60); + t.vy *= Math.pow(0.18, Math.max(0.016, dt) * 60); + t.memorialBuildProgress = Math.max(0, Number(t.memorialBuildProgress || 0)) + dt; + if (t.memorialBuildProgress >= 9.0) completeMemorial(t, req, splat); + return true; + } + + function maintainMourning(t, dt = 0) { + const now = Number(t?.world?.time || 0) || 0; + if (!t || !Number.isFinite(Number(t.graveMourningUntil)) || now >= Number(t.graveMourningUntil)) { + if (t?.state === "mourn_grave") t.goIdle?.("泣き止んだ"); + return false; + } + setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null); + if (dt > 0) { + t.vx *= Math.pow(0.15, dt * 60); + t.vy *= Math.pow(0.15, dt * 60); + } + return true; + } + + function maintainBellCall(t) { + const now = Number(t?.world?.time || 0) || 0; + if (!t || !t.bellCallTarget || now >= Number(t.bellCallUntil || 0)) { + if (t?.state === "bell_call") t.goIdle?.("呼び鈴の音が止んだ"); + t && (t.bellCallTarget = null); + return false; + } + if (isCritical(t) && t.state !== "bell_call") return false; + const target = t.bellCallTarget; + const d = Math.hypot(target.x - t.x, target.y - t.y); + if (d <= Math.max(32, (t.radius || 22) * 1.15)) { + t.bellCallTarget = null; + t.target = null; + t.goIdle?.("呼び鈴の鳴った場所に集まった。"); + return false; + } + setSpecialBehavior(t, "bell_call", BELL_TEXT, target); + return true; + } + + function updateAi(t) { + if (!t || t.dead) return false; + if (maintainMourning(t, 0)) return true; + if (t.memorialRequestId) { + const req = requestById(t.world, t.memorialRequestId); + if (req?.status === "pending") { + setSpecialBehavior(t, "build_memorial", BUILD_TEXT, req.splatId ? t.world?.residueByRuntimeId?.(req.splatId) : t.target); + return true; + } + clearBuilderAssignment(t, req); + } + if (maintainBellCall(t)) return true; + if (assignMemorial(t)) return true; + if (maintainGraveVisit(t)) return true; + return maybeStartGraveVisit(t); + } + + function updateMotion(t, dt) { + if (!t || t.dead) return { handled: false }; + if (maintainMourning(t, dt)) return { handled: true }; + if (t.memorialRequestId || t.state === "build_memorial") { + const handled = updateMemorialMotion(t, dt); + return { handled }; + } + if (maintainBellCall(t)) return { handled: false }; + if (t.graveVisitTargetId || t.state === "visit_grave") { + const grave = graveById(t.world, t.graveVisitTargetId); + if (!grave) { clearGraveVisit(t, "墓がなくなった。"); return { handled: false }; } + const d = Math.hypot((grave.x || 0) - t.x, (grave.y || 0) - t.y); + const reach = Math.max(38, (t.radius || 22) + (grave.r || 18) * 0.82); + if (t.graveVisitPhase === "visiting" || d <= reach) { + if (t.graveVisitPhase !== "visiting") { + const now = Number(t.world?.time || 0) || 0; + const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-duration-${grave.id || "grave"}-${Math.floor(now)}`) : Math.random(); + t.graveVisitPhase = "visiting"; + t.graveVisitUntil = now + 4.5 + unit * 4.0; + if (!t.graveVisitReliefApplied && typeof applyNeedRelief === "function") { + applyNeedRelief(t, { social: -42, fulfill: -6 }); + t.graveVisitReliefApplied = true; + } + setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}を悼んでいる。`, grave); + } + t.vx *= Math.pow(0.12, Math.max(0.016, dt) * 60); + t.vy *= Math.pow(0.12, Math.max(0.016, dt) * 60); + maintainGraveVisit(t); + return { handled: true }; + } + maintainGraveVisit(t); + return { handled: false }; + } + return { handled: false }; + } + + function ringAt(worldRef, x, y, requestedSize = "medium") { + if (!worldRef) return 0; + const size = ["small", "medium", "large"].includes(requestedSize) ? requestedSize : "medium"; + const range = BELL_RANGES[size]; + const duration = BELL_DURATIONS[size]; + const now = Number(worldRef.time || 0) || 0; + global.TarinaiAudio?.bell?.(); + const px = Number(x || 0) || 0; + const py = Number(y || 0) || 0; + const target = { id: `bell-call-${Math.round(now * 1000)}-${Math.round(px)}-${Math.round(py)}`, type: "bell_call", x: px, y: py, dead: false }; + let affected = 0; + for (const t of worldRef.tarinai || []) { + if (!t || t.dead) continue; + if (Math.hypot(t.x - px, t.y - py) > range) continue; + t.bellCallTarget = target; + t.bellCallUntil = Math.max(Number(t.bellCallUntil || 0), now + duration); + if (t.graveVisitTargetId) { + t.graveVisitTargetId = ""; + t.graveVisitUntil = 0; + t.graveVisitPhase = ""; + } + if (!isCritical(t)) setSpecialBehavior(t, "bell_call", BELL_TEXT, target); + affected += 1; + } + worldRef.spawnEffect?.("ring", px, py, { size: range, life: 0.62, color: "rgba(219,164,70,0.52)" }); + worldRef.spawnEffect?.("ring", px, py, { size: Math.min(92, 34 + range * 0.08), life: 0.48, color: "rgba(244,197,100,0.58)" }); + worldRef.drawListDirty = true; + return affected; + } + + const NATURAL_ASCENSION_SPRITES = Object.freeze(["sleep", "normal_happy"]); + const REMOVAL_ASCENSION_SPRITES = Object.freeze(["hurt", "flee", "flee_fear2", "cold_1"]); + + function ascensionSpriteFor(worldRef, grave, sprites, salt = "grave-ascension") { + const list = Array.isArray(sprites) && sprites.length ? sprites : ["sleep"]; + const unit = typeof global.stableUnit === "function" + ? global.stableUnit(grave?.memorialFamilyKey || grave?.id || "grave", `${salt}-${Math.floor(Number(worldRef?.time || 0) || 0)}`) + : Math.random(); + return list[Math.min(list.length - 1, Math.floor(Math.max(0, Math.min(0.999999, unit)) * list.length))] || list[0]; + } + + function spawnGraveAscension(worldRef, grave, options = {}) { + if (!worldRef || !grave) return false; + const natural = options.natural === true; + const spriteId = ascensionSpriteFor(worldRef, grave, natural ? NATURAL_ASCENSION_SPRITES : REMOVAL_ASCENSION_SPRITES, natural ? "grave-natural-ascension" : "grave-removal-ascension"); + worldRef.spawnEffect?.("ascend_soul", grave.x, grave.y - Math.max(2, (grave.r || 14) * 0.25), { + vx: 0, + vy: -34, + size: Math.max(18, (grave.r || 14) * 1.45), + life: 2.4, + importance: 3, + allowOffscreen: true, + forceVisual: true, + spriteId, + }); + return true; + } + + function applyExternalForce(worldRef, grave, magnitude = 0, breaker = null, reason = "external-force") { + if (!worldRef || !grave || grave.dead || grave.type !== "grave") return false; + const force = Math.max(0, Number(magnitude || 0) || 0); + if (force < GRAVE_BREAK_FORCE) return false; + const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, { + reason: `grave-${reason}`, + userReason: "強い衝撃で墓が壊れた", + wake: true, + breaker, + })); + if (removed) { + worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 42, life: 0.28, color: "rgba(130,124,116,0.46)" }); + worldRef.spawnEffect?.("fight", grave.x, grave.y, { size: 18, life: 0.24, color: "rgba(118,108,98,0.52)" }); + } + return removed; + } + + function onGraveDestroyed(worldRef, grave, breaker = null) { + if (!worldRef || !grave || grave.type !== "grave" || grave._memorialDestroyedHandled) return false; + grave._memorialDestroyedHandled = true; + if (grave._naturalDecaySilent) return false; + spawnGraveAscension(worldRef, grave, { natural: false }); + const eligible = new Set(Array.isArray(grave.memorialEligibleFamilyKeys) ? grave.memorialEligibleFamilyKeys : []); + const now = Number(worldRef.time || 0) || 0; + let count = 0; + for (const t of worldRef.tarinai || []) { + if (!t || t.dead || !eligible.has(familyKeyOf(t))) continue; + if (t.graveVisitTargetId === grave.id) { + t.graveVisitTargetId = ""; + t.graveVisitUntil = 0; + t.graveVisitPhase = ""; + } + t.graveMourningUntil = Math.max(Number(t.graveMourningUntil || 0), now + 5.5); + t.bellCallTarget = null; + t.memorialRequestId = ""; + setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.75); + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.5); + t.stress = Math.min(100, (Number(t.stress || 0) || 0) + 12); + worldRef.spawnBubble?.(t.x, t.y - (t.radius || 20) * 1.15, "……", "rgba(84,112,166,0.82)"); + count += 1; + } + if (count) worldRef.log?.(`${grave.memorialName || "親しいたりない"}の墓が壊され、親しいたりないたちが泣いている。`, "death"); + void breaker; + return count > 0; + } + + function removeGraveNaturally(worldRef, grave, day) { + if (!worldRef || !grave || grave.dead || grave.type !== "grave" || grave.memorialFavorite) return false; + const disappears = typeof global.deterministicChance === "function" + ? global.deterministicChance(worldRef, "grave-natural-decay-0444", 0.25, grave.memorialFamilyKey || grave.id, day) + : Math.random() < 0.25; + if (!disappears) return false; + grave._naturalDecaySilent = true; + spawnGraveAscension(worldRef, grave, { natural: true }); + const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, { + reason: "grave-natural-decay", + userReason: "いつの間にか消えた", + wake: false, + panicOwner: false, + })); + if (removed) worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 30, life: 0.36, color: "rgba(164,156,143,0.26)", importance: 2 }); + return removed; + } + + function updateWorldNaturalDecay(worldRef) { + if (!worldRef) return 0; + const now = Math.max(0, Number(worldRef.time || 0) || 0); + const hasPrevious = Number.isFinite(Number(worldRef._graveNaturalDecayLastWorldTime)); + const previous = hasPrevious ? Number(worldRef._graveNaturalDecayLastWorldTime) : now; + worldRef._graveNaturalDecayLastWorldTime = now; + if (!hasPrevious || now <= previous) return 0; + + const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120); + const targetFraction = (4 * 60 + 44) / (24 * 60); + const firstDay = Math.max(0, Math.floor(previous / dayLength)); + const lastDay = Math.max(firstDay, Math.floor(now / dayLength)); + let removedCount = 0; + for (let day = firstDay; day <= lastDay; day += 1) { + if (Number(worldRef._graveNaturalDecayLastCheckedDay) === day) continue; + const targetTime = day * dayLength + dayLength * targetFraction; + if (!(previous < targetTime && now >= targetTime)) continue; + worldRef._graveNaturalDecayLastCheckedDay = day; + const graveBucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grave") : null; + const graves = graveBucket && typeof graveBucket.length === "number" + ? Array.from(graveBucket) + : (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave"); + for (const grave of graves) if (removeGraveNaturally(worldRef, grave, day)) removedCount += 1; + } + return removedCount; + } + + // Kept for compatibility with older direct callers. Grave decay is now + // world-scheduled, so adding more graves does not add per-grave update work. + function updateGraveNaturalDecay(grave, worldRef) { + void grave; + void worldRef; + return false; + } + + function formatWorldTime(timeValue) { + const value = Number(timeValue); + if (!Number.isFinite(value)) return ""; + const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120); + const completedDays = Math.max(0, Math.floor(value / dayLength)); + const season = ["春", "夏", "秋", "冬"][Math.floor((completedDays % 20) / 5)] || "春"; + const seasonDay = (completedDays % 5) + 1; + const progress = (((value % dayLength) + dayLength) % dayLength) / dayLength; + const totalMinutes = Math.floor(progress * 24 * 60 + 1e-7) % (24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${season}${seasonDay}日 ${hh}:${mm}`; + } + + function graveTooltipLines(grave) { + if (!grave) return []; + const lines = ["墓"]; + if (grave.memorialName) lines.push(`故人: ${grave.memorialName}`); + const died = formatWorldTime(grave.memorialDeathTime); + if (died) lines.push(`死亡日時: ${died}`); + if (grave.memorialDeathReason) lines.push(`死因: ${grave.memorialDeathReason}`); + return lines; + } + + global.TarinaiMemorialBellSystem = Object.freeze({ + BUILD_TEXT, + MOURN_TEXT, + BELL_TEXT, + GRAVE_VISIT_APPROACH_TEXT, + GRAVE_VISIT_TEXT, + BELL_RANGES, + MEMORIAL_BUILD_CHANCE, + GRAVE_BREAK_FORCE, + captureDeath, + ringAt, + updateAi, + updateMotion, + applyExternalForce, + onGraveDestroyed, + updateGraveNaturalDecay, + updateWorldNaturalDecay, + graveTooltipLines, + formatWorldTime, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/perf_profiler.js b/js/perf_profiler.js index c6a1466..813d746 100644 --- a/js/perf_profiler.js +++ b/js/perf_profiler.js @@ -18,7 +18,6 @@ let frameCount = 0; let lastSmoothAt = 0; const storageKey = "tarinai_visual_settings_v2"; - const legacyStorageKey = "tarinai_visual_settings_v1"; const allowed = { effects: new Set(["off", "high"]), shadows: new Set(["off", "high"]), @@ -32,32 +31,34 @@ dprScale: 1, simulationThrottled: false, creatureCadenceScale: 0, - creatureBackgroundBudget: 1000000000, - pressureCreatureFullBudget: 1000000000, - pressureCreatureMotionBudget: 1000000000, - pressureObstacleContactBudget: 1000000000, - collisionSourceBudget: 1000000000, - collisionImpactBudget: 1000000000, + // High quality is still intentionally generous, but no subsystem is + // allowed to explode to effectively-unbounded work in a single frame. + creatureBackgroundBudget: 180, + pressureCreatureFullBudget: 240, + pressureCreatureMotionBudget: 360, + pressureObstacleContactBudget: 96, + collisionSourceBudget: 220, + collisionImpactBudget: 160, itemCadenceScale: 0, - scheduledItemBudget: 1000000000, - aiBudget: 1000000000, - envBudget: 1000000000, - effectCap: 1000000000, - effectUpdateBudget: 1000000000, + scheduledItemBudget: 180, + aiBudget: 96, + envBudget: 32, + effectCap: 520, + effectUpdateBudget: 280, effectDrawBudget: 160, - fireInteractionBudget: 1000000000, - activeFireLimit: 1000000000, + fireInteractionBudget: 96, + activeFireLimit: 260, physicsBudget: Object.freeze({ - maxItems: 1000000000, - maxPairs: 1000000000, - maxLinks: 1000000000, - postMaxPairs: 1000000000, - maxSubsteps: 8, - focusThreshold: 1000000000, - maxSubstepPairs: 1000000000, - maxSubstepFenceItems: 1000000000, + maxItems: 560, + maxPairs: 960, + maxLinks: 520, + postMaxPairs: 300, + maxSubsteps: 6, + focusThreshold: 220, + maxSubstepPairs: 620, + maxSubstepFenceItems: 220, }), - poisonContactBudget: Object.freeze({ maxPoison: 1000000000, maxTargets: 1000000000, skip: false }), + poisonContactBudget: Object.freeze({ maxPoison: 72, maxTargets: 320, skip: false }), }); const PROFILE_BALANCED = Object.freeze({ id: "balanced", @@ -130,6 +131,7 @@ let visualSettings = loadVisualSettings(); let resizeRequested = false; let autoProfileId = "full"; + const FPS_UPDATE_INTERVAL_SECONDS = 0.75; let fpsAccumSeconds = 0; let fpsAccumFrames = 0; let smoothedFps = 0; @@ -213,14 +215,7 @@ function loadVisualSettings() { try { const current = global.localStorage?.getItem(storageKey); - if (current) return sanitizeVisualSettings(JSON.parse(current)); - const legacy = global.localStorage?.getItem(legacyStorageKey); - if (!legacy) return { ...defaults }; - const migrated = sanitizeVisualSettings(JSON.parse(legacy)); - // Old builds had no automatic mode. Preserve explicit effect/shadow choices, - // but use the new automatic drawing mode as the migration default. - migrated.details = "auto"; - return migrated; + return current ? sanitizeVisualSettings(JSON.parse(current)) : { ...defaults }; } catch (_) { return { ...defaults }; } @@ -271,10 +266,10 @@ if (!Number.isFinite(dt) || dt <= 0 || dt > 0.25 || global.document?.hidden) return; fpsAccumSeconds += dt; fpsAccumFrames += 1; - if (fpsAccumSeconds < 0.75) return; + if (fpsAccumSeconds < FPS_UPDATE_INTERVAL_SECONDS) return; const sampleSeconds = fpsAccumSeconds; const sampledFps = fpsAccumFrames / Math.max(0.001, sampleSeconds); - smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps; + smoothedFps = sampledFps; global.__tarinaiFps = Math.round(smoothedFps); fpsAccumSeconds = 0; fpsAccumFrames = 0; diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 11d2b77..59b4ae1 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -215,8 +215,10 @@ bounceInsideWorld(item, worldRef, opts); resolveFenceBounce(item, worldRef, dt, opts); const ice = (worldRef?.groundType || "soil") === "ice"; - const baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68); - const frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1); + let baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68); + let frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1); + const oilMul = global.TarinaiItemEnvironmentHazardSystem?.groundFrictionMultiplier?.(worldRef, item.x, item.y) ?? 1; + if (oilMul < 1) baseFriction = 1 - (1 - baseFriction) * oilMul; const friction = Math.pow(baseFriction, dt * frictionRate); item.vx *= friction; item.vy *= friction; diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js index e4a2d3e..0a67953 100644 --- a/js/placement_preview_system.js +++ b/js/placement_preview_system.js @@ -23,7 +23,6 @@ } function pickMode(worldRef) { const value = String(worldRef?.toolPickMode || "free").toLowerCase(); - if (value === "grid") return "grid40"; // compatibility with v39.16.34 return new Set(["grid20", "grid40", "grid60"]).has(value) ? value : "free"; } function gridStep(worldRef) { @@ -225,6 +224,9 @@ if (type === "dry_ice" || type === "stove") { const range = Math.max(24, 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); + extra.influence = { kind: "leakageDamageRange", shape: "circle", x, y, radius: range, type }; } if (type === "grass") { const overlaps = global.TarinaiPhysicsSoftClear.collectForPlacement(worldRef, tmp) || []; diff --git a/js/render.js b/js/render.js index 6ec7897..6953529 100644 --- a/js/render.js +++ b/js/render.js @@ -510,8 +510,9 @@ function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = ctx.lineWidth = 2.0; const tempRange = influence?.kind === "temperatureEffectRange"; const hotRange = tempRange && influence?.type === "stove"; - ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)"); - ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)"); + const electricRange = influence?.kind === "leakageDamageRange"; + ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (electricRange ? "rgba(126,218,255,0.90)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)")); + ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (electricRange ? "rgba(126,218,255,0.085)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)")); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); @@ -687,6 +688,33 @@ function drawPressureSwitchRangeOverlay(ctx, world) { function drawOperationToolPreview(ctx, world) { const p = world?.pointer; if (!p?.inside) return; + if (world?.tool === "bell") { + const size = world.toolSizeFor?.("bell") || "medium"; + const ranges = globalThis.TarinaiMemorialBellSystem?.BELL_RANGES || { small: 120, medium: 210, large: 350 }; + const range = Math.max(1, Number(ranges[size] || ranges.medium || 420) || 420); + ctx.save(); + ctx.fillStyle = "rgba(229, 174, 70, 0.055)"; + ctx.strokeStyle = "rgba(214, 151, 48, 0.76)"; + ctx.lineWidth = Math.max(1.4, world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2); + ctx.setLineDash([12, 9]); + ctx.beginPath(); + ctx.arc(p.x, p.y, range, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([4, 10]); + ctx.globalAlpha = 0.62; + ctx.beginPath(); + ctx.arc(p.x, p.y, range * 0.5, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 0.88; + ctx.fillStyle = "rgba(214, 151, 48, 0.88)"; + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(4, world.screenSizeToWorld ? world.screenSizeToWorld(5) : 5), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + return; + } if (world?.tool === "copy" && world.copyBuffer) { const type = world.copyBuffer.type || ""; const item = world.copyPreviewItemAt?.(p.x, p.y); @@ -877,8 +905,9 @@ function invalidateRenderCaches(reason = "manual") { if (backgroundCache) backgroundCache.key = ""; if (terrainChunkCache) { terrainChunkCache.key = ""; - terrainChunkCache.chunks?.clear?.(); + clearTerrainChunkCache(); } + if (typeof clearSharedStaticBitmapCaches === "function") clearSharedStaticBitmapCaches(); } // Cached low-priority terrain layer: grass, footprints and splats are mostly @@ -890,8 +919,38 @@ const terrainChunkCache = { size: 256, key: "", chunks: new Map(), + minEntries: 64, + spareEntries: 24, }; +function releaseTerrainChunk(chunk) { + if (!chunk?.canvas) return; + chunk.canvas.width = 0; + chunk.canvas.height = 0; +} + +function clearTerrainChunkCache() { + for (const chunk of terrainChunkCache.chunks.values()) releaseTerrainChunk(chunk); + terrainChunkCache.chunks.clear(); +} + +function touchTerrainChunk(key, chunk) { + // Map insertion order is our LRU order. Refresh entries when they are used. + terrainChunkCache.chunks.delete(key); + terrainChunkCache.chunks.set(key, chunk); +} + +function pruneTerrainChunkCache(visibleKeys, visibleCount) { + const maxEntries = Math.max(terrainChunkCache.minEntries, Math.max(0, visibleCount || 0) + terrainChunkCache.spareEntries); + if (terrainChunkCache.chunks.size <= maxEntries) return; + for (const [key, chunk] of terrainChunkCache.chunks) { + if (terrainChunkCache.chunks.size <= maxEntries) break; + if (visibleKeys?.has?.(key)) continue; + terrainChunkCache.chunks.delete(key); + releaseTerrainChunk(chunk); + } +} + function isCachedTerrainItem(it) { if (!it || it.dead) return false; if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true; @@ -933,7 +992,7 @@ function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) { } function isBehindSpriteLayerItem(it) { - return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw")); + return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "oil" || it.type === "bear_trap" || it.type === "seesaw")); } function renderLayerSortY(entity) { @@ -986,8 +1045,10 @@ function terrainChunkFor(cx, cy, worldRef) { const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y))); if (!chunk) { const canvas = document.createElement("canvas"); - chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" }; + chunk = { cacheKey: key, canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" }; terrainChunkCache.chunks.set(key, chunk); + } else { + touchTerrainChunk(key, chunk); } if (chunk.w !== w || chunk.h !== h) { chunk.w = w; @@ -997,6 +1058,40 @@ function terrainChunkFor(cx, cy, worldRef) { return chunk; } +function drawTerrainResidue(ctx, residue) { + if (!residue || residue.dead || residue.amount <= 0) return; + ctx.save(); + ctx.translate(residue.x || 0, residue.y || 0); + if (residue.type === "trace") { + const fade = Math.max(0, Math.min(1, (Number(residue.amount || 0) || 0) / 220)); + ctx.globalAlpha *= 0.35 + fade * 0.65; + ctx.fillStyle = "rgba(122, 100, 76, 0.12)"; + ctx.strokeStyle = "rgba(122, 100, 76, 0.16)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.ellipse(0, 0, (residue.r || 16) * 1.3, (residue.r || 16) * 0.75, Math.sin(residue.seed || 0) * 0.7, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } else if (residue.type === "splat") { + const fade = Math.max(0, Math.min(1, (Number(residue.amount || 0) || 0) / 260)); + ctx.globalAlpha *= fade; + ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`; + ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`; + ctx.lineWidth = 1.8; + const blobs = [[-12,-2,10,8],[0,0,16,10],[13,5,10,8],[-2,-12,9,7],[7,-8,7,5],[-18,8,6,5]]; + for (const [bx, by, bw, bh] of blobs) { + ctx.beginPath(); + ctx.ellipse(bx, by, bw, bh, randSeed((residue.seed || 0) + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + for (let i = 0; i < 7; i++) { + ctx.beginPath(); + ctx.arc(randSeed((residue.seed || 0) + i, -28, 24), randSeed((residue.seed || 0) + i + 9, -18, 18), randSeed((residue.seed || 0) + i + 19, 2.5, 4.8), 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} + function redrawTerrainChunk(chunk, worldRef, lighting) { const c = chunk.ctx; chunk.canvas.width = Math.max(1, chunk.w); @@ -1016,6 +1111,10 @@ function redrawTerrainChunk(chunk, worldRef, lighting) { if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue; it.draw(c, worldRef.time || 0, lighting); } + for (const residue of worldRef.nearbyResidues?.(cx, cy, radius) || []) { + if (!residue || residue.dead || !isPointVisibleInRect(residue.x, residue.y, (residue.r || 24) + 42, rect)) continue; + drawTerrainResidue(c, residue); + } } catch (err) { console.error("terrain chunk redraw failed", err); } finally { @@ -1032,7 +1131,7 @@ function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) { const key = terrainCacheKey(worldRef, lighting); if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) { terrainChunkCache.key = key; - terrainChunkCache.chunks.clear(); + clearTerrainChunkCache(); worldRef.terrainDirty = false; worldRef.terrainDirtyGlobal = false; worldRef.terrainDirtyChunks?.clear?.(); @@ -1049,13 +1148,28 @@ function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) { const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size)); const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size)); const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size)); - for (let cy = minY; cy <= maxY; cy++) { - for (let cx = minX; cx <= maxX; cx++) { - const chunk = terrainChunkFor(cx, cy, worldRef); - if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting); - ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h); - } + const visibleChunks = worldRef._renderTerrainChunkScratch || (worldRef._renderTerrainChunkScratch = []); + const visibleChunkKeys = worldRef._renderTerrainChunkKeyScratch || (worldRef._renderTerrainChunkKeyScratch = new Set()); + visibleChunks.length = 0; + visibleChunkKeys.clear(); + let dirtyVisibleCount = 0; + for (let cy = minY; cy <= maxY; cy++) for (let cx = minX; cx <= maxX; cx++) { + const chunk = terrainChunkFor(cx, cy, worldRef); + visibleChunks.push(chunk); + visibleChunkKeys.add(chunk.cacheKey || terrainChunkKey(cx, cy)); + if (chunk.dirty || chunk.key !== terrainChunkCache.key) dirtyVisibleCount += 1; } + const deathBurst = /tarinai-death|splat-fade/.test(String(worldRef.terrainDirtyReason || "")) && dirtyVisibleCount > 8; + let redrawBudget = deathBurst ? 6 : Infinity; + if (deathBurst) visibleChunks.sort((a, b) => ((a.key === terrainChunkCache.key) ? 1 : 0) - ((b.key === terrainChunkCache.key) ? 1 : 0)); + for (const chunk of visibleChunks) { + if (chunk.dirty || chunk.key !== terrainChunkCache.key) { + const usable = chunk.key === terrainChunkCache.key || (chunk.canvas.width > 1 && chunk.canvas.height > 1); + if (redrawBudget > 0 || !usable) { redrawTerrainChunk(chunk, worldRef, lighting); redrawBudget -= 1; } + } + ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h); + } + pruneTerrainChunkCache(visibleChunkKeys, visibleChunks.length); } finally { if (end) end(); } @@ -1119,7 +1233,7 @@ const STATIC_ITEM_BITMAP_TYPES = new Set([ // corpses are cheaper to draw directly and can exist in large numbers, so // per-item canvases for them waste substantially more memory than they save. "stone", "bed", "grass_bed", "nest_box", "pipe", "toilet", "rain_shelter", "ant_nest", "signboard", - "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "one_way_fence", "plushie" + "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "one_way_fence", "plushie", "grave" ]); function canUseStaticItemBitmapCache(it, worldRef) { @@ -1151,32 +1265,10 @@ function staticItemBitmapState(it, lighting) { flash: Number(it.toiletFlash ?? 0), color: String(it.color || ""), active: Boolean(it.active), + memorialFavorite: Boolean(it.memorialFavorite), }; } -function staticItemBitmapStateMatches(cache, state) { - return cache.lightBucket === state.lightBucket - && cache.warmthBucket === state.warmthBucket - && cache.r === state.r - && cache.angle === state.angle - && cache.amount === state.amount - && cache.servings === state.servings - && cache.servingsMax === state.servingsMax - && cache.variant === state.variant - && cache.toolSize === state.toolSize - && cache.text === state.text - && cache.state === state.state - && cache.freshness === state.freshness - && cache.fertility === state.fertility - && cache.flash === state.flash - && cache.color === state.color - && cache.active === state.active; -} - -function assignStaticItemBitmapState(cache, state) { - Object.assign(cache, state); -} - function releaseStaticItemBitmapCache(it) { const cache = it?._staticRenderBitmapCache; if (!cache) return false; @@ -1190,6 +1282,96 @@ function releaseStaticItemBitmapCache(it) { return true; } +const SHARED_STATIC_ITEM_BITMAP_CACHE_MAX = 128; +const sharedStaticItemBitmapCache = new Map(); +const sharedGraveBitmapCache = new Map(); + +function clearSharedStaticBitmapCaches() { + for (const cache of sharedStaticItemBitmapCache.values()) { + if (cache?.canvas) { cache.canvas.width = 0; cache.canvas.height = 0; } + } + for (const cache of sharedGraveBitmapCache.values()) { + if (cache?.canvas) { cache.canvas.width = 0; cache.canvas.height = 0; } + } + sharedStaticItemBitmapCache.clear(); + sharedGraveBitmapCache.clear(); +} + +function sharedStaticBitmapKey(it, state, radius) { + // Procedural decorations use a small stable variant pool instead of one + // canvas per item. This preserves visual variety while bounding memory. + const seedVariant = Math.abs(Math.floor(Number(it.seed || 0))) % 4; + return [ + String(it.type || ""), radius, seedVariant, + state.lightBucket, state.warmthBucket, state.r, state.angle, + state.amount, state.servings, state.servingsMax, state.variant, + state.toolSize, state.text, state.state, state.freshness, + state.fertility, state.flash, state.color, state.active ? 1 : 0, + state.memorialFavorite ? 1 : 0, + ].join("|"); +} + +function getSharedStaticBitmap(key) { + const cache = sharedStaticItemBitmapCache.get(key); + if (!cache) return null; + sharedStaticItemBitmapCache.delete(key); + sharedStaticItemBitmapCache.set(key, cache); + return cache; +} + +function putSharedStaticBitmap(key, cache) { + sharedStaticItemBitmapCache.set(key, cache); + while (sharedStaticItemBitmapCache.size > SHARED_STATIC_ITEM_BITMAP_CACHE_MAX) { + const oldestKey = sharedStaticItemBitmapCache.keys().next().value; + const oldest = sharedStaticItemBitmapCache.get(oldestKey); + sharedStaticItemBitmapCache.delete(oldestKey); + if (oldest?.canvas) { oldest.canvas.width = 0; oldest.canvas.height = 0; } + } +} + +function drawSharedGraveBitmapCached(ctx, it, time, lighting, worldRef) { + const radius = Math.ceil(Math.max(42, renderRadiusForEntity(it) + 18)); + const size = radius * 2; + const key = [ + Math.round((lighting?.light || 0) * 6), + Math.round((lighting?.warmth || 0) * 3), + Number(it.r || 0), + it.memorialFavorite ? 1 : 0, + it.memorialTarinaiKing ? 1 : 0, + it.memorialZunchiSlave ? 1 : 0, + size, + ].join(":"); + let cache = sharedGraveBitmapCache.get(key); + if (cache) { + sharedGraveBitmapCache.delete(key); + sharedGraveBitmapCache.set(key, cache); + } + if (!cache) { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const c = canvas.getContext("2d"); + c.clearRect(0, 0, size, size); + c.save(); + c.translate(radius - (it.x || 0), radius - (it.y || 0)); + const pointer = worldRef?.pointer; + const oldInside = pointer?.inside; + if (pointer) pointer.inside = false; + try { it.draw(c, time, lighting); } + finally { if (pointer) pointer.inside = oldInside; c.restore(); } + cache = { canvas, radius }; + sharedGraveBitmapCache.set(key, cache); + while (sharedGraveBitmapCache.size > 20) { + const oldestKey = sharedGraveBitmapCache.keys().next().value; + const oldest = sharedGraveBitmapCache.get(oldestKey); + sharedGraveBitmapCache.delete(oldestKey); + if (oldest?.canvas) { oldest.canvas.width = 0; oldest.canvas.height = 0; } + } + } + ctx.drawImage(cache.canvas, (it.x || 0) - cache.radius, (it.y || 0) - cache.radius); + return true; +} + function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) { if (!canUseStaticItemBitmapCache(it, worldRef)) { releaseStaticItemBitmapCache(it); @@ -1203,18 +1385,24 @@ function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) { it.draw(ctx, time, lighting); return false; } + if (it.type === "grave") { + releaseStaticItemBitmapCache(it); + return drawSharedGraveBitmapCached(ctx, it, time, lighting, worldRef); + } + // Clear any legacy per-item bitmap left by an older build. Static artwork is + // now shared by visual state, so hundreds of identical objects reuse a small + // bounded set of backing canvases. + releaseStaticItemBitmapCache(it); const state = staticItemBitmapState(it, lighting); - let cache = it._staticRenderBitmapCache; const radius = Math.ceil(Math.max(42, renderRadiusForEntity(it) + 18)); const size = radius * 2; - if (!cache) cache = it._staticRenderBitmapCache = { canvas: document.createElement("canvas"), ctx: null, radius: 0 }; - if (!cache.ctx) cache.ctx = cache.canvas.getContext("2d"); - if (!staticItemBitmapStateMatches(cache, state) || cache.radius !== radius || cache.canvas.width !== size || cache.canvas.height !== size) { - cache.radius = radius; - assignStaticItemBitmapState(cache, state); - cache.canvas.width = size; - cache.canvas.height = size; - const c = cache.ctx; + const key = sharedStaticBitmapKey(it, state, radius); + let cache = getSharedStaticBitmap(key); + if (!cache) { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const c = canvas.getContext("2d"); c.clearRect(0, 0, size, size); c.save(); c.translate(radius - (it.x || 0), radius - (it.y || 0)); @@ -1227,8 +1415,10 @@ function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) { if (pointer) pointer.inside = oldInside; c.restore(); } + cache = { canvas, radius }; + putSharedStaticBitmap(key, cache); } - ctx.drawImage(cache.canvas, (it.x || 0) - radius, (it.y || 0) - radius); + ctx.drawImage(cache.canvas, (it.x || 0) - cache.radius, (it.y || 0) - cache.radius); // Pointer hover is not baked into the bitmap. Draw only the hovered outline // live so cached objects still react immediately to the cursor. const pointer = worldRef?.pointer; @@ -1309,8 +1499,21 @@ function mergeSortedRenderEntries(a, b, out) { return out; } +function backItemLayerRank(item) { + if (!item || item.dead) return 1; + // Ground decorations stay behind sleeping places. Oil is still below all + // creatures, but sits visually in front of ordinary beds. + if (item.type === "toilet") return 0; + if (item.type === "bed") return 1; + if (item.type === "oil" || item.type === "bear_trap") return 2; + if (item.type === "grass_bed") return 3; + return 1; +} + function compareBackItems(a, b) { - return (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b)) || (a.x || 0) - (b.x || 0); + return backItemLayerRank(a) - backItemLayerRank(b) + || (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b)) + || (a.x || 0) - (b.x || 0); } function mergeSortedBackItems(a, b, out) { @@ -1382,7 +1585,7 @@ function collectVisibleRenderStack(worldRef, visibleRect, creatureSortRect = vis const staticScratch = worldRef._renderVisibleStaticScratch || (worldRef._renderVisibleStaticScratch = []); const cacheRect = { left: visibleRect.left - 52, top: visibleRect.top - 52, right: visibleRect.right + 52, bottom: visibleRect.bottom + 52 }; staticScratch.length = 0; - worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, cacheRect, staticScratch); + worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells, cacheRect, staticScratch); const oldVisibleRect = visibleRect; visibleRect = cacheRect; for (const it of staticScratch) addItemTo(it, staticCache.backItems, staticCache.layered); @@ -2073,10 +2276,25 @@ function render() { const endEffects = renderPerfBegin("render.draw.effects"); const effectLevel = renderVisualLevel("effects"); let drawnEffects = 0; + let drawnAscensions = 0; + let drawnBubbles = 0; const maxEffects = visualEffectLimit(effectLevel); const effects = world.effects || []; const totalEffects = effects.length; let effectScanCap = 0; + const bubbleDrawLimit = Math.max(1, Number(CONFIG.bubbleLimit || 32) || 32); + for (let i = 0; i < totalEffects; i += 1) { + const ef = effects[i]; + if (!ef || ef.dead) continue; + if (ef.type === "ascend_soul") { + if (isEntityVisibleInRect(ef, visibleRect, 180)) { ef.draw(ctx); drawnAscensions += 1; } + continue; + } + if (ef.type === "bubble" && drawnBubbles < bubbleDrawLimit && isEntityVisibleInRect(ef, visibleRect, 72)) { + ef.draw(ctx); + drawnBubbles += 1; + } + } if (totalEffects > 0 && maxEffects > 0) { const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects; effectScanCap = scanCap; @@ -2084,6 +2302,7 @@ function render() { for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) { const ef = effects[cursor]; cursor = (cursor + 1) % totalEffects; + if (!ef || ef.dead || ef.type === "ascend_soul" || ef.type === "bubble") continue; if (isEntityVisibleInRect(ef, visibleRect)) { ef.draw(ctx); drawnEffects += 1; @@ -2093,12 +2312,12 @@ function render() { } if (window.TarinaiPerf?.diagnosticsEnabled?.() === true) { world._effectRenderStats = { - total: totalEffects, drawn: drawnEffects, skipped: Math.max(0, totalEffects - drawnEffects), + total: totalEffects, drawn: drawnEffects + drawnAscensions + drawnBubbles, skipped: Math.max(0, totalEffects - drawnEffects - drawnAscensions - drawnBubbles), budget: maxEffects, scanCap: effectScanCap, level: effectLevel, }; - window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects); + window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects + drawnAscensions + drawnBubbles); window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects); - window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects)); + window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects - drawnAscensions - drawnBubbles)); } else world._effectRenderStats = null; renderPerfEnd(endEffects); drawPressureSwitchRangeOverlay(ctx, world); diff --git a/js/robot_cleaner_system.js b/js/robot_cleaner_system.js index 8195def..227c8e4 100644 --- a/js/robot_cleaner_system.js +++ b/js/robot_cleaner_system.js @@ -14,8 +14,9 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains grass: 1 << 3, grass_bed: 1 << 4, water: 1 << 5, + grave: 1 << 6, }); - const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]); + const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water", "grave"]); function robotCleanerDefaultMask() { return ROBOT_DEFAULT_MASK; @@ -28,7 +29,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) { if (!item || item.type !== "robot_cleaner") return false; - item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0)); + item.robotCleanerMask = Math.max(0, Math.min(0x7f, Number(mask) | 0)); return true; } @@ -62,6 +63,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains case "grass": return "\u8349"; case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9"; case "water": return "\u6c34\u6ef4"; + case "grave": return "\u5893"; default: return "\u5bfe\u8c61"; } } @@ -69,17 +71,65 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains function normalizeRobotCleanerState(item) { if (!item || item.type !== "robot_cleaner") return null; if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK; - item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0)); + item.robotCleanerMask = Math.max(0, Math.min(0x7f, Number(item.robotCleanerMask) | 0)); item.amount = Math.max(Number(item.amount || 0) || 0, 999); if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0; if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0; if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0; if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0; + if (!item.robotCleanerUnreachableTargets || typeof item.robotCleanerUnreachableTargets !== "object") item.robotCleanerUnreachableTargets = Object.create(null); + if (!Number.isFinite(Number(item.robotCleanerPathFailTimer))) item.robotCleanerPathFailTimer = 0; + if (!Number.isFinite(Number(item.robotCleanerProgressTimer))) item.robotCleanerProgressTimer = 0; + if (!Number.isFinite(Number(item.robotCleanerProgressDistance))) item.robotCleanerProgressDistance = Infinity; + if (typeof item.robotCleanerProgressTargetId !== "string") item.robotCleanerProgressTargetId = ""; item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item); return item; } + function robotTargetRuntimeId(target) { + return String(target?.id || target?.runtimeId || target?._runtimeId || ""); + } + + function robotTargetTemporarilyUnreachable(item, target, worldRef) { + const id = robotTargetRuntimeId(target); + if (!id) return false; + const record = item?.robotCleanerUnreachableTargets?.[id]; + if (!record) return false; + const now = Number(worldRef?.time || 0) || 0; + if (typeof record === "number") return record > now; + const moved = distXY(Number(record.x || 0), Number(record.y || 0), Number(target.x || 0), Number(target.y || 0)) > 18; + const routeChanged = Number(record.obstacleVersion || 0) !== Number(worldRef?.routingObstacleVersion || 0); + if (moved || routeChanged || Number(record.until || 0) <= now) { + delete item.robotCleanerUnreachableTargets[id]; + return false; + } + return true; + } + + function markRobotTargetUnreachable(item, target, worldRef, seconds = 60) { + if (!item || !target) return false; + const id = robotTargetRuntimeId(target); + if (!id) return false; + if (!item.robotCleanerUnreachableTargets || typeof item.robotCleanerUnreachableTargets !== "object") item.robotCleanerUnreachableTargets = Object.create(null); + item.robotCleanerUnreachableTargets[id] = { + until: (Number(worldRef?.time || 0) || 0) + Math.max(15, Number(seconds || 60) || 60), + x: Number(target.x || 0) || 0, + y: Number(target.y || 0) || 0, + obstacleVersion: Number(worldRef?.routingObstacleVersion || 0) || 0, + }; + if (String(item.robotCleanerTargetId || "") === id) { + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; + item.robotCleanerScanTimer = 0; + } + item.robotCleanerPathFailTimer = 0; + item.robotCleanerProgressTimer = 0; + item.robotCleanerProgressDistance = Infinity; + item.robotCleanerProgressTargetId = ""; + return true; + } + function robotTargetAlive(target) { if (!target) return false; return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y)); @@ -96,12 +146,13 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (!key || !id || !worldRef) return null; if (key === "tarinai") { const t = worldRef.liveTarinaiById?.(id) || (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead); - return robotTargetAlive(t) ? t : null; + return robotTargetAlive(t) && !robotTargetTemporarilyUnreachable(item, t, worldRef) ? t : null; } const byId = typeof worldRef.itemById === "function" ? worldRef.itemById(id) : null; - const found = byId || (worldRef.items || []).find(it => it && it.id === id && !it.dead); + const found = byId || worldRef.residueByRuntimeId?.(id) || (worldRef.items || []).find(it => it && it.id === id && !it.dead); const matchesKey = found && (found.type === key || (key === "ant_corpse" && (found.type === "trace" || found.type === "splat"))); - return matchesKey && robotTargetAlive(found) ? found : null; + if (!matchesKey || !robotTargetAlive(found) || robotTargetTemporarilyUnreachable(item, found, worldRef)) return null; + return found; } function robotTargetCandidates(item, worldRef) { @@ -116,15 +167,22 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains for (const t of worldRef.tarinai || []) { if (!t || t.dead) continue; if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + if (robotTargetTemporarilyUnreachable(item, t, worldRef)) continue; out.push({ target: t, type: key }); } continue; } const buckets = key === "ant_corpse" - ? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type)) + ? [ + ...(worldRef.itemsOfType?.("ant_corpse") || []), + ...(worldRef.residuesOfType?.("trace") || []), + ...(worldRef.residuesOfType?.("splat") || []), + ] : (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key)); for (const it of buckets || []) { if (!it || it.dead || it === item) continue; + if (key === "grave" && it.memorialFavorite) continue; + if (robotTargetTemporarilyUnreachable(item, it, worldRef)) continue; out.push({ target: it, type: key }); } } @@ -150,8 +208,14 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (score < bestD) { best = target; bestType = rec.type; bestD = score; } } if (best) { - item.robotCleanerTargetId = best.id || ""; + item.robotCleanerTargetId = best.id || best.runtimeId || best._runtimeId || ""; item.robotCleanerTargetType = bestType || best.type || ""; + if (item.robotCleanerProgressTargetId !== String(item.robotCleanerTargetId || "")) { + item.robotCleanerProgressTargetId = String(item.robotCleanerTargetId || ""); + item.robotCleanerProgressDistance = distXY(item.x, item.y, best.x, best.y); + item.robotCleanerProgressTimer = 0; + item.robotCleanerPathFailTimer = 0; + } } return best; } @@ -199,6 +263,10 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains function cleanRobotTarget(item, target, worldRef, typeOverride = "") { if (!item || !target || !worldRef || target.dead) return false; + if (target.type === "grave" && target.memorialFavorite) { + clearRobotTrackedTargetIfMatched(item, target); + return false; + } const type = String(typeOverride || item.robotCleanerTargetType || target.type || ""); const label = robotTargetLabel(type); let removed = false; @@ -215,6 +283,8 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains worldRef.markDead?.(target, cleanReason); removed = true; } + } else if (type === "trace" || type === "splat") { + removed = Boolean(worldRef.removeResidue?.(target, "robot-cleaner")); } else { removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, { reason: "robot-cleaner", @@ -237,7 +307,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains worldRef.queueEffect?.("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }); worldRef.markItemBucketsDirty?.("robot-cleaner"); worldRef.markSpatialDirty?.("robot-cleaner"); - if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner"); worldRef.updateItemCounts?.(); worldRef.drawListDirty = true; return true; @@ -281,11 +350,24 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains preferFullPath: true, maxChecks: 54, }); - if (waypoint) moveTarget = waypoint; - else desiredSpeed = speed * 0.35; + if (waypoint) { + moveTarget = waypoint; + item.robotCleanerPathFailTimer = 0; + } else { + item.robotCleanerPathFailTimer = Math.max(0, Number(item.robotCleanerPathFailTimer || 0) || 0) + dt; + desiredSpeed = speed * 0.18; + if (item.robotCleanerPathFailTimer >= 0.75) { + markRobotTargetUnreachable(item, target, worldRef, 90); + target = null; + desiredSpeed = speed * 0.62; + } + } + } else { + item.robotCleanerPathFailTimer = 0; } - desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0)); - } else if ((item.robotCleanerWanderTimer || 0) <= 0) { + if (target) desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0)); + } + if (!target && (item.robotCleanerWanderTimer || 0) <= 0) { desiredAngle = global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2)); item.robotCleanerWanderTimer = global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item); } @@ -300,6 +382,28 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains item.prevY = item.y; item.x += item.vx * dt; item.y += item.vy * dt; + if (target && robotTargetAlive(target) && !isRobotTarinaiTarget(target)) { + const targetId = robotTargetRuntimeId(target); + const currentDistance = distXY(item.x, item.y, target.x, target.y); + if (item.robotCleanerProgressTargetId !== targetId) { + item.robotCleanerProgressTargetId = targetId; + item.robotCleanerProgressDistance = currentDistance; + item.robotCleanerProgressTimer = 0; + } else if (currentDistance <= Number(item.robotCleanerProgressDistance || Infinity) - 7) { + item.robotCleanerProgressDistance = currentDistance; + item.robotCleanerProgressTimer = 0; + } else { + item.robotCleanerProgressTimer = Math.max(0, Number(item.robotCleanerProgressTimer || 0) || 0) + dt; + if (item.robotCleanerProgressTimer >= 3.0 && currentDistance > robotCleanerCaptureRadius(item, target) * 1.35) { + markRobotTargetUnreachable(item, target, worldRef, 75); + target = null; + } + } + } else if (!target) { + item.robotCleanerProgressTargetId = ""; + item.robotCleanerProgressDistance = Infinity; + item.robotCleanerProgressTimer = 0; + } const minX = CONFIG.worldPadding + r * 0.5; const minY = CONFIG.worldPadding + r * 0.5; const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5); diff --git a/js/save_schema.js b/js/save_schema.js index 4906bdc..f81df62 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -8,7 +8,7 @@ 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 = 40; + const SNAPSHOT_VERSION = 41; const BINARY_SCHEMA_VERSION = 53; function moodValue(index, fallback = "relaxed") { diff --git a/js/seesaw_system.js b/js/seesaw_system.js index 3f4486e..1cb5cb5 100644 --- a/js/seesaw_system.js +++ b/js/seesaw_system.js @@ -185,7 +185,7 @@ const side = index === 1 ? 1 : -1; const lift = Math.sin(item.seesawPhase) * 9 * side; actor.x = base.x; - actor.y = base.y - 12 + (playing ? lift : 0); + actor.y = base.y - 16 + (playing ? lift : 0); actor.vx = 0; actor.vy = 0; const actionText = playing ? "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b"; actor.thought = actionText; diff --git a/js/signal_system.js b/js/signal_system.js index bbbf547..97726ae 100644 --- a/js/signal_system.js +++ b/js/signal_system.js @@ -5,7 +5,7 @@ const WIRE_TYPES = new Set(["wire", "insulated_wire"]); const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]); const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]); - const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]); + const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator", "leakage_device"]); const DETECTOR_METRICS = Object.freeze({ tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 }, hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 }, @@ -154,9 +154,10 @@ return actors.length; } - function updatePressureSwitches(worldRef) { + function updatePressureSwitches(worldRef, pressureSwitches = null) { const minute = currentMinuteOfDay(worldRef); - for (const item of worldRef.itemsOfType?.("pressure_switch") || []) { + const switches = pressureSwitches || worldRef.itemsOfType?.("pressure_switch") || []; + for (const item of switches) { if (!item || item.dead) continue; item.pressureTarget = normalizePressureTarget(item.pressureTarget); const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1; @@ -205,7 +206,62 @@ } } - function buildSignalNetwork(worldRef) { + function collectSignalSinks(worldRef, out) { + if (!worldRef || !out) return out; + if (typeof worldRef.itemsOfType === "function") { + for (const type of DIRECT_SIGNAL_SINK_TYPES) { + const bucket = worldRef.itemsOfType(type) || []; + if (bucket.length) out.sinks.push(...bucket); + } + return out; + } + for (const item of worldRef.items || []) { + if (item && !item.dead && DIRECT_SIGNAL_SINK_TYPES.has(item.type)) out.sinks.push(item); + } + return out; + } + + function collectSignalBuckets(worldRef, includeSinks = true) { + const out = { pressureSwitches: [], boards: [], wires: [], sinks: [] }; + if (!worldRef) return out; + if (typeof worldRef.itemsOfType === "function") { + out.pressureSwitches = worldRef.itemsOfType("pressure_switch") || []; + out.boards = worldRef.itemsOfType("circuit_board") || []; + out.wires = [ + ...(worldRef.itemsOfType("wire") || []), + ...(worldRef.itemsOfType("insulated_wire") || []), + ]; + if (includeSinks) collectSignalSinks(worldRef, out); + return out; + } + // Compatibility fallback for lightweight test worlds that do not expose + // item buckets. Production worlds use the branch above and avoid a full + // world.items scan entirely. + for (const item of worldRef.items || []) { + if (!item || item.dead) continue; + if (item.type === "pressure_switch") out.pressureSwitches.push(item); + else if (item.type === "circuit_board") out.boards.push(item); + if (WIRE_TYPES.has(item.type)) out.wires.push(item); + if (includeSinks && DIRECT_SIGNAL_SINK_TYPES.has(item.type)) out.sinks.push(item); + } + return out; + } + + function clearSignalSinks(worldRef, sinks) { + let changed = false; + for (const item of sinks || []) { + if (!item || item.dead) continue; + if (item._signalDriven || item._signalOn || item._wireSignalDriven || item._wireSignalOn) changed = true; + item._wireSignalDriven = false; + item._wireSignalOn = false; + setEffectivePower(item, false, false, worldRef); + } + if (changed) worldRef.drawListDirty = true; + return changed; + } + + function buildSignalNetwork(worldRef, signalBuckets = null) { + const buckets = signalBuckets || collectSignalBuckets(worldRef); const adjacency = new Map(); const connectedNodes = new Set(); const wires = []; @@ -219,20 +275,19 @@ connectedNodes.add(a); connectedNodes.add(b); }; const boards = []; - for (const item of worldRef.items || []) { + for (const item of buckets.sinks || []) { if (!item || item.dead) continue; - if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) { - item._wireSignalDriven = false; item._wireSignalOn = false; - item._directSignalDriven = false; item._directSignalOn = false; - } - if (item.type === "circuit_board") { - circuit?.ensureConfig?.(item); - item._circuitInputState = Object.create(null); - if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState); - boards.push(item); - } + item._wireSignalDriven = false; item._wireSignalOn = false; + item._directSignalDriven = false; item._directSignalOn = false; } - for (const wire of worldRef.items || []) { + for (const item of buckets.boards || []) { + if (!item || item.dead) continue; + circuit?.ensureConfig?.(item); + item._circuitInputState = Object.create(null); + if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState); + boards.push(item); + } + for (const wire of buckets.wires || []) { if (!isWire(wire)) continue; const pb = global.TarinaiPhysicsBodySystem; const endpointA = pb?.endpoint?.(wire, 0); @@ -324,13 +379,10 @@ global.TarinaiAchievements?.recordCircuitBoardSignalTransfer?.(sourceBoard, targetBoard, entry.wire, { world: worldRef }); } - for (const item of worldRef.items || []) { - if (!item || item.dead) continue; - if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) { - item._wireSignalDriven = false; item._wireSignalOn = false; - setEffectivePower(item, false, false, worldRef); - } - if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false; + for (const item of buckets.sinks || []) { + if (!item || item.dead || componentOf.has(item)) continue; + item._wireSignalDriven = false; item._wireSignalOn = false; + setEffectivePower(item, false, false, worldRef); } if (wireGeometryMoved) { worldRef.drawListDirty = true; @@ -339,8 +391,6 @@ return wires; } - const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by); - function shockTarget(worldRef, wire, target, now, { connected = false } = {}) { if (!target || target.dead) return false; if (!wire._shockCooldowns || typeof wire._shockCooldowns.get !== "function") wire._shockCooldowns = new WeakMap(); @@ -391,13 +441,13 @@ const near = worldRef.nearbyTarinai?.(cx, cy, radius, true) || worldRef.tarinai || []; for (const t of near) { if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - if (distanceToSegment(t.x, t.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(8, num(t.radius, 20) * 0.48)) continue; + if (global.TarinaiGeometry.pointSegmentDistance(t.x, t.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(8, num(t.radius, 20) * 0.48)) continue; shockTarget(worldRef, wire, t, now, { connected: false }); } const ants = worldRef.nearbyAnts?.(cx, cy, radius, true) || worldRef.ants || []; for (const ant of ants) { if (!ant || ant.dead) continue; - if (distanceToSegment(ant.x, ant.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(5, num(ant.r, 6) * 0.9)) continue; + if (global.TarinaiGeometry.pointSegmentDistance(ant.x, ant.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(5, num(ant.r, 6) * 0.9)) continue; shockTarget(worldRef, wire, ant, now, { connected: false }); } } @@ -409,10 +459,30 @@ } function updateWorld(worldRef, dt = 0.016) { - if (!worldRef?.items) return false; - updatePressureSwitches(worldRef); - const wires = buildSignalNetwork(worldRef); - damageFromWires(worldRef, wires); + if (!worldRef) return false; + const buckets = collectSignalBuckets(worldRef, false); + const hasSwitches = buckets.pressureSwitches.length > 0; + const hasNetwork = buckets.wires.length > 0 || buckets.boards.length > 0; + + // Normal worlds with no signal controllers stop after four O(1) bucket + // lookups. Sink buckets are only touched while a network exists or once + // when a removed network needs to clear stale power flags. + if (!hasSwitches && !hasNetwork && !worldRef._signalSystemHadNetwork) return false; + + if (hasSwitches) updatePressureSwitches(worldRef, buckets.pressureSwitches); + if (!hasNetwork) { + if (worldRef._signalSystemHadNetwork) { + collectSignalSinks(worldRef, buckets); + clearSignalSinks(worldRef, buckets.sinks); + } + worldRef._signalSystemHadNetwork = false; + return hasSwitches; + } + + collectSignalSinks(worldRef, buckets); + const wires = buildSignalNetwork(worldRef, buckets); + worldRef._signalSystemHadNetwork = true; + if (wires.length) damageFromWires(worldRef, wires); return true; } @@ -429,5 +499,6 @@ DETECTOR_METRICS, PRESSURE_TARGETS, detectorMetricValue, + normalizeMinute, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sim_core.js b/js/sim_core.js index b25c805..7e58880 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -109,8 +109,12 @@ function inheritedGenetics(a, b, seed = "") { const fallback = key === "temperatureOffset" ? 0 : 1; const av = Number.isFinite(Number(ga[key])) ? Number(ga[key]) : fallback; const bv = Number.isFinite(Number(gb[key])) ? Number(gb[key]) : fallback; - const base = (av + bv) / 2; - const mutationScale = key === "temperatureOffset" ? 2.5 : 0.10; + // Keep offspring much closer to the parental range. A small deterministic + // dominance bias prevents every lineage from collapsing immediately to the + // exact midpoint, while mutation is intentionally modest. + const aWeight = 0.42 + stableUnit(seed, `gene-parent-weight-${key}`) * 0.16; + const base = av * aWeight + bv * (1 - aWeight); + const mutationScale = key === "temperatureOffset" ? 0.6 : 0.024; const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * mutationScale; out[key] = clampGeneticValue(key, base + mutation); } @@ -419,8 +423,10 @@ class Effect { this.color = options.color || "#fff4a8"; this.seed = Math.random() * 1000; this.text = options.text || ""; + this.spriteId = options.spriteId || (type === "ascend_soul" ? "sleep" : ""); this._effectPooled = false; this._effectUpdatedFrame = 0; + this._forceVisual = options.forceVisual === true || type === "ascend_soul"; return this; } @@ -428,6 +434,11 @@ class Effect { this.life -= dt; this.x += this.vx * dt; this.y += this.vy * dt; + if (this.type === "ascend_soul") { + this.vx *= Math.pow(0.985, dt * 60); + this.vy *= Math.pow(0.997, dt * 60); + return; + } this.vx *= Math.pow(0.92, dt * 60); this.vy *= Math.pow(0.92, dt * 60); } @@ -455,6 +466,42 @@ class Effect { const s = this.size * (0.80 + (1 - alpha) * 0.36); ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18); drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 }); + } else if (this.type === "ascend_soul") { + const phase = 1 - alpha; + const spriteId = this.spriteId || "sleep"; + const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, spriteId) : null; + const sway = Math.sin(this.seed + phase * Math.PI * 3.2) * this.size * 0.12; + const scale = 0.86 + phase * 0.18; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + const pillarH = this.size * 6.4; + const pillarW = this.size * (1.35 + phase * 0.35); + const gradient = ctx.createLinearGradient(0, -pillarH, 0, this.size * 0.8); + gradient.addColorStop(0, "rgba(244,248,255,0)"); + gradient.addColorStop(0.28, `rgba(238,246,255,${0.16 * alpha})`); + gradient.addColorStop(0.72, `rgba(255,247,207,${0.30 * alpha})`); + gradient.addColorStop(1, "rgba(255,244,194,0)"); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.ellipse(0, -pillarH * 0.40, pillarW * 0.58, pillarH * 0.58, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = Math.min(0.75, alpha * 0.62); + ctx.fillStyle = "rgba(255,250,220,0.72)"; + ctx.fillRect(-Math.max(1.5, this.size * 0.075), -pillarH * 0.92, Math.max(3, this.size * 0.15), pillarH * 0.95); + ctx.restore(); + ctx.translate(sway, 0); + ctx.globalAlpha = Math.min(1, alpha * 1.18) * (0.82 + Math.sin(phase * Math.PI) * 0.18); + if (img && img.complete && (img.naturalWidth || img.width)) { + const ratio = (img.naturalHeight || img.height || 1) / Math.max(1, (img.naturalWidth || img.width || 1)); + const w = this.size * 2.25 * scale; + const h = w * ratio; + ctx.drawImage(img, -w * 0.5, -h * 0.72, w, h); + } else { + ctx.fillStyle = "rgba(245,248,255,0.72)"; + ctx.beginPath(); + ctx.ellipse(0, -this.size * 0.35, this.size * 0.62, this.size * 0.46, 0, 0, Math.PI * 2); + ctx.fill(); + } } else if (this.type === "ring") { ctx.strokeStyle = this.color; ctx.lineWidth = 2; @@ -671,7 +718,6 @@ globalThis.TarinaiEffect = Effect; class SpatialGrid { constructor(cellSize = 96) { this.cellSize = cellSize; - this.itemCells = new Map(); this.staticItemCells = new Map(); this.dynamicItemCells = new Map(); // Grass is passive terrain/food. Keep a second item index without grass so @@ -687,9 +733,6 @@ class SpatialGrid { this._indexedTarinai = new Set(); this._tarinaiSyncStamp = 0; this.antCells = new Map(); - this.obstacleCells = new Map(); - this.foodCells = new Map(); - this.hazardCells = new Map(); this.staticObstacleCells = new Map(); this.dynamicObstacleCells = new Map(); this.staticFoodCells = new Map(); @@ -705,16 +748,12 @@ class SpatialGrid { } clearItems() { - this.itemCells.clear(); this.staticItemCells.clear(); this.dynamicItemCells.clear(); this.staticNonGrassItemCells.clear(); this.dynamicNonGrassItemCells.clear(); this.grassCells.clear(); this.linkRenderCells.clear(); - this.obstacleCells.clear(); - this.foodCells.clear(); - this.hazardCells.clear(); this.staticObstacleCells.clear(); this.dynamicObstacleCells.clear(); this.staticFoodCells.clear(); @@ -916,7 +955,6 @@ class SpatialGrid { this.add(target, it); if (it.type === "grass") this.add(this.grassCells, it); else this.add(dynamic ? this.dynamicNonGrassItemCells : this.staticNonGrassItemCells, it); - this.add(this.itemCells, it); this.addLinkRenderItem(it); this.addItemToTraitCells(it, dynamic); } @@ -924,7 +962,6 @@ class SpatialGrid { rebuildItems(items) { this.clearItems(); for (const it of items || []) this.classifyItem(it); - this.rebuildCombinedItemCells(); } rebuildStaticItems(items, opts = {}) { @@ -941,7 +978,6 @@ class SpatialGrid { else this.add(this.staticNonGrassItemCells, it); this.addItemToTraitCells(it, false); } - if (opts.rebuildCombined) this.rebuildCombinedItemCells(); } rebuildLinkRenderItems(items) { @@ -961,33 +997,6 @@ class SpatialGrid { if (it.type !== "grass") this.add(this.dynamicNonGrassItemCells, it); this.addItemToTraitCells(it, true); } - if (opts.rebuildCombined) this.rebuildCombinedItemCells(); - } - - copyCells(src, dst) { - for (const [key, bucket] of src.entries()) { - let out = dst.get(key); - if (!out) { - out = []; - dst.set(key, out); - } - for (const entity of bucket) out.push(entity); - } - } - - rebuildCombinedItemCells() { - this.itemCells.clear(); - this.obstacleCells.clear(); - this.foodCells.clear(); - this.hazardCells.clear(); - this.copyCells(this.staticItemCells, this.itemCells); - this.copyCells(this.dynamicItemCells, this.itemCells); - this.copyCells(this.staticObstacleCells, this.obstacleCells); - this.copyCells(this.dynamicObstacleCells, this.obstacleCells); - this.copyCells(this.staticFoodCells, this.foodCells); - this.copyCells(this.dynamicFoodCells, this.foodCells); - this.copyCells(this.staticHazardCells, this.hazardCells); - this.copyCells(this.dynamicHazardCells, this.hazardCells); } rebuildTarinai(tarinai) { diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js index b1c5151..51fc8b5 100644 --- a/js/simulation_creature_system.js +++ b/js/simulation_creature_system.js @@ -264,14 +264,13 @@ stats.full += 1; if (cadence.visible) stats.visibleFullUsed += 1; if (usesBackgroundBudget) stats.fullBudgetUsed += 1; - // Off-screen creatures used to receive the accumulated AI/timer dt but - // only one rendered-frame worth of movement (about 0.016s). At high - // zoom this effectively froze creatures after they left the viewport. - // Catch up a bounded amount of motion on their scheduled full update; - // the movement step uses the light collision path for this catch-up. + // Camera zoom must never change simulation travel distance. Off-screen + // creatures receive the same accumulated movement time as metabolism, + // AI timers, and health. The light collision path keeps the catch-up + // affordable while moveTarinaiWithCollision still substeps long travel. const runContext = cadence.visible ? context - : { ...context, motionDt: Math.min(runDt, cadence.near ? 0.45 : 0.75), offscreenCatchup: true }; + : { ...context, motionDt: Math.min(runDt, 2.4), offscreenCatchup: true }; global.TarinaiCreatureRuntime.updateOne(t, runDt, { context: runContext }); } else if (cadence.smoothMotion) { if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) { diff --git a/js/simulation_effects_system.js b/js/simulation_effects_system.js index c8cf771..ae72c8f 100644 --- a/js/simulation_effects_system.js +++ b/js/simulation_effects_system.js @@ -4,8 +4,10 @@ // Owns frame updates for transient visual/effect entities. (function (global) { function effectPriority(effect) { + const explicit = Number(effect?._effectImportance); + if (Number.isFinite(explicit)) return Math.max(0, Math.min(3, explicit)); const type = String(effect?.type || ""); - if (type === "explosion" || type === "shoot_impact" || type === "bubble" || type === "heart" || type === "electric_shock") return 3; + if (type === "explosion" || type === "shoot_impact" || type === "bubble" || type === "heart" || type === "electric_shock" || type === "ascend_soul") return 3; if (type === "ring" || type === "fall") return 2; return 1; } @@ -29,6 +31,7 @@ for (let priority = 1; priority <= 3 && needDrop > 0; priority += 1) { for (let i = 0; i < effects.length && needDrop > 0; i += 1) { if (dropMask[i] || effectPriority(effects[i]) !== priority) continue; + if (effects[i]?._forceVisual === true || effects[i]?.type === "bubble" || effects[i]?.type === "ascend_soul") continue; dropMask[i] = 1; needDrop -= 1; } @@ -60,18 +63,33 @@ return true; } - function clearDisabledEffects(worldRef, effects) { - if (!effects?.length) return; - for (const effect of effects) if (effect) worldRef.releaseEffect?.(effect); - effects.length = 0; + function clearDisabledEffects(worldRef, effects, dt) { + if (!effects?.length) return 0; + let write = 0; + let updated = 0; + for (const effect of effects) { + if (!effect) continue; + const forced = effect._forceVisual === true || effect.type === "bubble" || effect.type === "ascend_soul"; + if (!forced) { + worldRef.releaseEffect?.(effect); + continue; + } + if (!effect.dead) effect.update(Math.min(0.24, Math.max(0, dt))); + if (!effect.dead) { + effects[write++] = effect; + updated += 1; + } else worldRef.releaseEffect?.(effect); + } + effects.length = write; worldRef._effectUpdateCursor = 0; worldRef._renderEffectCursor = 0; + return updated; } function updateEffects(worldRef, dt) { const effects = worldRef.effects || []; if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") { - clearDisabledEffects(worldRef, effects); + clearDisabledEffects(worldRef, effects, dt); worldRef._effectUpdateStats = null; return; } diff --git a/js/simulation_maintenance_system.js b/js/simulation_maintenance_system.js index d3b642c..3fb4242 100644 --- a/js/simulation_maintenance_system.js +++ b/js/simulation_maintenance_system.js @@ -24,6 +24,9 @@ const now = Number(worldRef.time || 0) || 0; let itemsCompacted = false; + worldRef.updateResidues?.(dt); + global.TarinaiMemorialBellSystem?.updateWorldNaturalDecay?.(worldRef); + // Link/plushie reconciliation is a safety net. Deletions already reconcile // dependencies immediately, so a periodic pass is sufficient during normal play. if (now >= (worldRef._nextStructureDependencyCheckAt || 0)) { diff --git a/js/simulation_runtime_helpers.js b/js/simulation_runtime_helpers.js index ccb7150..10860cb 100644 --- a/js/simulation_runtime_helpers.js +++ b/js/simulation_runtime_helpers.js @@ -45,11 +45,9 @@ if (!worldRef) return false; const thresholdSq = threshold * threshold; let moved = false; - const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef); - const source = tracked && tracked.length ? tracked : null; + const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef) || []; const visit = (entity) => { moved = trackSpatialEntityMoved(entity, thresholdSq) || moved; }; - if (source) for (const entity of source) visit(entity); - else forScheduledItems(worldRef, visit); + for (const entity of tracked) visit(entity); if (moved) worldRef.markSpatialDirty?.(reason); return moved; } diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 25ba4b8..49cb3a3 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -317,8 +317,10 @@ (worldRef.items || []).forEach((item, index) => { const row = Array.isArray(itemRows[index]) ? itemRows[index] : []; item._achievementPlayerPlaced = Boolean(row[0]); + item._achievementPlayerPlacedCounted = item._achievementPlayerPlaced; item._achievementPlacedAt = row[1] != null && Number.isFinite(Number(row[1])) ? Number(row[1]) : null; }); + worldRef.achievementPlayerPlacedActiveCount = (worldRef.items || []).reduce((count, item) => count + (item && !item.dead && item._achievementPlayerPlaced ? 1 : 0), 0); return true; } @@ -434,12 +436,25 @@ const owner = tarinaiIndex.get(item.ownerId) ?? -1; const carried = item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1; const consumed = item.singleUseConsumedById ? (tarinaiIndex.get(item.singleUseConsumedById) ?? -1) : -1; - return [q(item.hp, 10), q(item.maxHp, 10), owner, carried, item.singleUsePending ? 1 : 0, consumed, q(item.createdAt, 10)]; + const base = [q(item.hp, 10), q(item.maxHp, 10), owner, carried, item.singleUsePending ? 1 : 0, consumed, q(item.createdAt, 10)]; + if (type === "grave") return base.concat([ + String(item.memorialFamilyKey || ""), + String(item.memorialName || ""), + String(item.memorialType || ""), + String(item.memorialDeathReason || ""), + q(item.memorialDeathTime, 10, -1), + String(item.memorialBuilderFamilyKey || ""), + JSON.stringify(Array.isArray(item.memorialEligibleFamilyKeys) ? item.memorialEligibleFamilyKeys : []), + item.memorialFavorite ? 1 : 0, + item.memorialTarinaiKing ? 1 : 0, + item.memorialZunchiSlave ? 1 : 0, + ]); + 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 === "signboard") return [item.text || ""]; - if (type === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0]; + 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 || {})]; if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) { const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex); @@ -482,9 +497,32 @@ } function compactItem(item, index, tarinaiIndex = new Map(), itemIndex = new Map()) { const typeId = itemTypeIndex(item?.type || "", -1); - if (typeId < 0 || item?.type === "trace") return null; + if (typeId < 0) return null; return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex, itemIndex)]; } + function compactResidue(residue) { + if (!residue || residue.dead || Number(residue.amount || 0) <= 0) return null; + const typeCode = residue.type === "splat" ? 1 : (residue.type === "trace" ? 0 : -1); + if (typeCode < 0) return null; + return [ + typeCode, + q(residue.x, 1), + q(residue.y, 1), + q(residue.amount, 10), + q(residue.r, 10, typeCode === 1 ? 260 : 160), + q(residue.seed, 100, 0), + ]; + } + function restoreResidueRow(worldRef, row) { + if (!worldRef || !Array.isArray(row)) return null; + const type = Number(row[0]) === 1 ? "splat" : "trace"; + return worldRef.addResidue?.(type, u(row[1], 1), u(row[2], 1), { + amount: u(row[3], 10, type === "splat" ? 260 : 220), + r: u(row[4], 10, type === "splat" ? 26 : 16), + seed: u(row[5], 100, 0), + reason: "restore-residue", + }) || null; + } function applyItemExtra(item, extra, tarinaiList = [], itemList = []) { const type = item.type || ""; if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) return; @@ -504,6 +542,19 @@ if (type === "plushie") { item.onHead = Boolean(item.carriedById); } + if (type === "grave") { + item.memorialFamilyKey = String(extra[7] || ""); + item.memorialName = String(extra[8] || ""); + item.memorialType = String(extra[9] || ""); + item.memorialDeathReason = String(extra[10] || ""); + item.memorialDeathTime = Number(extra[11]) >= 0 ? u(extra[11], 10, null) : null; + item.memorialBuilderFamilyKey = String(extra[12] || ""); + try { item.memorialEligibleFamilyKeys = JSON.parse(String(extra[13] || "[]")); } catch (_) { item.memorialEligibleFamilyKeys = []; } + if (!Array.isArray(item.memorialEligibleFamilyKeys)) item.memorialEligibleFamilyKeys = []; + item.memorialFavorite = Boolean(extra[14]); + item.memorialTarinaiKing = Boolean(extra[15]); + item.memorialZunchiSlave = Boolean(extra[16]); + } const def = global.StructureRegistry?.get?.(type); if (def) { item.label = def.label; @@ -521,7 +572,7 @@ item.text = String(extra[0] || ""); } else if (type === "robot_cleaner") { const mask = Number(extra[0]); - item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); + item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x7f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); item.robotCleanerHighSpeed = Boolean(extra[1]); item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60); item.robotCleanerTargetId = ""; @@ -598,7 +649,7 @@ tarinaiLive.forEach((t, i) => { if (t.id) tarinaiIndex.set(t.id, i); if (t.familyKey) familyIndex.set(t.familyKey, i); }); const itemRecords = []; for (const it of (worldRef.items || [])) { - if (!it || it.dead || it.type === "trace") continue; + if (!it || it.dead || it.type === "trace" || it.type === "splat") continue; const typeId = itemTypeIndex(it.type || "", -1); if (typeId < 0) continue; itemRecords.push({ item: it, sortTypeId: typeId, sortX: q(it.x, 1), sortY: q(it.y, 1) }); @@ -606,6 +657,12 @@ itemRecords.sort((a, b) => (a.sortTypeId || 0) - (b.sortTypeId || 0) || mortonKeyXY(a.sortX, a.sortY) - mortonKeyXY(b.sortX, b.sortY) || String(a.item?.id || "").localeCompare(String(b.item?.id || ""))); const itemIndex = new Map(); itemRecords.forEach((rec, i) => { if (rec.item?.id) itemIndex.set(rec.item.id, i); }); + const residueRows = (worldRef.residues || []) + .filter(residue => residue && !residue.dead && Number(residue.amount || 0) > 0) + .slice() + .sort((a, b) => String(a.type || "").localeCompare(String(b.type || "")) || mortonKeyXY(a.x, a.y) - mortonKeyXY(b.x, b.y)) + .map(compactResidue) + .filter(Boolean); const itemRows = itemRecords.map((rec, i) => compactItem(rec.item, i, tarinaiIndex, itemIndex)).filter(Boolean); return { v: SNAPSHOT_VERSION, @@ -614,6 +671,7 @@ w: compactWorld(worldRef), t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)), i: itemRows, + r: residueRows, g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords, options), }; } @@ -674,6 +732,7 @@ applyItemExtra(item, item.__savedExtra || [], worldRef.tarinai, worldRef.items); delete item.__savedExtra; } + for (const row of (Array.isArray(snapshot.r) ? snapshot.r : [])) restoreResidueRow(worldRef, row); for (const t of worldRef.tarinai) { if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null; if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null; diff --git a/js/sound_pack.js b/js/sound_pack.js index 26e78a7..d749cea 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -23,6 +23,7 @@ sfx_firecracker_explode: 1.70, sfx_shoot: 0.725, sfx_bolt_action: 1.35, + sfx_bell: 1.00, voice_major_damage_01: 1.0, voice_major_damage_02: 1.0, voice_major_damage_03: 1.0, @@ -59,6 +60,7 @@ sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3", sfx_shoot: "assets/sounds/shoot_pistol.mp3", sfx_bolt_action: "assets/sounds/shoot_bolt_action.mp3", + sfx_bell: "assets/sounds/bicycle_bell.mp3", voice_major_damage_01: "assets/sounds/major_damage_01.wav", voice_major_damage_02: "assets/sounds/major_damage_02.wav", voice_major_damage_03: "assets/sounds/major_damage_03.wav", @@ -83,7 +85,7 @@ sfx_ui_click: "ops", sfx_ui_fold: "ops", sfx_place: "ops", sfx_delete: "ops", sfx_poke: "ops", sfx_grab: "ops", sfx_drop: "ops", sfx_water_hose: "ops", sfx_drink: "ops", sfx_ball_hit: "ops", sfx_stone_impact: "ops", sfx_firecracker_fuse: "ops", sfx_firecracker_explode: "ops", - sfx_shoot: "ops", sfx_bolt_action: "ops", + sfx_shoot: "ops", sfx_bolt_action: "ops", sfx_bell: "ops", sfx_genkotsu_fall: "ops", sfx_genkotsu_impact: "ops", sfx_ground_rumble: "ops", sfx_grass: "ops", sfx_zunchi: "ops", sfx_ant_nest: "ops" } diff --git a/js/structure_lifecycle.js b/js/structure_lifecycle.js index 4551c0c..fefe54c 100644 --- a/js/structure_lifecycle.js +++ b/js/structure_lifecycle.js @@ -283,6 +283,7 @@ const changed = markStructureGone(structure, reason); if (!changed) return false; worldRef.noteItemInactive?.(structure, reason); + if (structure.type === "grave") global.TarinaiMemorialBellSystem?.onGraveDestroyed?.(worldRef, structure, opts.breaker || null); removeDependentLinks(worldRef, structure, reason); if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason); markWorldAfterStructureChange(worldRef, reason); @@ -305,6 +306,7 @@ if (!item || item.dead) return false; const reason = opts.reason || "delete-tool"; if (item.isStructure) return deleteStructure(worldRef, item, { reason, userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner }); + if (item.type === "poison_gas_can") global.TarinaiItemEnvironmentHazardSystem?.spawnPoisonGas?.(worldRef, item); const changed = markLooseItemGone(item, reason); if (!changed) return false; worldRef.noteItemInactive?.(item, reason); diff --git a/js/structures.js b/js/structures.js index 3425936..c4bdd7a 100644 --- a/js/structures.js +++ b/js/structures.js @@ -103,6 +103,9 @@ this.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02); if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8); } + if (this.type === "grave") { + if (global.TarinaiMemorialBellSystem?.updateGraveNaturalDecay?.(this, worldRef)) return; + } if (this.type === "grass_bed") { if (!Number.isFinite(this.hp) || this.hp <= 0) { this.hp = Math.max(1, Number(this.maxHp || 100) || 100); @@ -258,6 +261,86 @@ ctx.beginPath(); ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "grave") { + const favoriteMemorial = Boolean(this.memorialFavorite); + const kingMemorial = Boolean(this.memorialTarinaiKing); + const slaveMemorial = Boolean(this.memorialZunchiSlave); + const graveScale = slaveMemorial ? 0.72 : (kingMemorial ? 1.10 : 1); + ctx.scale(graveScale, graveScale); + const stone = night ? (kingMemorial ? "rgb(139,139,149)" : favoriteMemorial ? "rgb(126,126,133)" : "rgb(111,112,116)") : (kingMemorial ? "rgb(197,190,174)" : favoriteMemorial ? "rgb(181,176,164)" : "rgb(163,158,149)"); + const edge = night ? "rgb(58,60,66)" : "rgb(91,87,80)"; + ctx.fillStyle = "rgba(56,47,40,0.18)"; + ctx.beginPath(); ctx.ellipse(0, this.r * 0.58, this.r * 1.02, this.r * 0.28, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = stone; + ctx.strokeStyle = edge; + ctx.lineWidth = kingMemorial ? 2.6 : 2; + ctx.beginPath(); + ctx.moveTo(-this.r * 0.78, this.r * 0.22); + ctx.bezierCurveTo(-this.r * 0.88, -this.r * 0.38, -this.r * 0.46, -this.r * 0.80, this.r * 0.06, -this.r * 0.72); + ctx.bezierCurveTo(this.r * 0.62, -this.r * 0.68, this.r * 0.88, -this.r * 0.20, this.r * 0.72, this.r * 0.30); + ctx.bezierCurveTo(this.r * 0.44, this.r * 0.64, -this.r * 0.50, this.r * 0.62, -this.r * 0.78, this.r * 0.22); + ctx.closePath(); ctx.fill(); ctx.stroke(); + ctx.fillStyle = night ? "rgba(165,166,174,0.42)" : "rgba(225,220,208,0.48)"; + ctx.beginPath(); ctx.ellipse(-this.r * 0.18, -this.r * 0.30, this.r * 0.30, this.r * 0.13, -0.35, 0, Math.PI * 2); ctx.fill(); + + if (favoriteMemorial || kingMemorial) { + ctx.fillStyle = night ? "rgb(101,102,108)" : "rgb(154,150,143)"; + const stones = kingMemorial ? [ + [-1.02,0.40,0.42,0.26,-0.18],[0.94,0.42,0.38,0.24,0.18],[-0.66,0.62,0.29,0.18,0.10],[0.58,0.63,0.28,0.18,-0.12],[0.02,0.72,0.24,0.14,0] + ] : [ + [-0.92,0.43,0.34,0.22,-0.18],[0.82,0.48,0.29,0.19,0.20],[-0.58,0.58,0.22,0.14,0.10] + ]; + for (const stoneSpec of stones) { + ctx.beginPath(); + ctx.ellipse(this.r * stoneSpec[0], this.r * stoneSpec[1], this.r * stoneSpec[2], this.r * stoneSpec[3], stoneSpec[4], 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + } + + const drawMemorialFlower = (flowerX, scale = 1, lean = 0, flowerY = 0) => { + const flowerBaseY = this.r * (0.50 + flowerY); + const flowerTopY = -this.r * (0.05 + 0.03 * scale - flowerY); + ctx.strokeStyle = night ? "rgb(72,114,73)" : "rgb(76,139,72)"; + ctx.lineWidth = Math.max(1.1, this.r * 0.085 * scale); + ctx.beginPath(); ctx.moveTo(flowerX, flowerBaseY); + ctx.quadraticCurveTo(flowerX - this.r * (0.10 + lean), this.r * 0.20, flowerX + this.r * lean, flowerTopY); ctx.stroke(); + ctx.fillStyle = night ? "rgb(128,149,104)" : "rgb(112,166,92)"; + ctx.beginPath(); ctx.ellipse(flowerX - this.r * 0.10, this.r * 0.23, this.r * 0.18 * scale, this.r * 0.09 * scale, -0.55, 0, Math.PI * 2); ctx.fill(); + const petal = night ? "rgb(205,190,203)" : "rgb(246,219,232)"; + const petalR = Math.max(2.0, this.r * 0.14 * scale); + ctx.fillStyle = petal; + const cx = flowerX + this.r * lean; + for (let i = 0; i < 5; i++) { + const a = -Math.PI / 2 + i * Math.PI * 2 / 5; + ctx.beginPath(); ctx.arc(cx + Math.cos(a) * petalR * 0.78, flowerTopY + Math.sin(a) * petalR * 0.78, petalR * 0.66, 0, Math.PI * 2); ctx.fill(); + } + ctx.fillStyle = night ? "rgb(196,153,70)" : "rgb(235,175,56)"; + ctx.beginPath(); ctx.arc(cx, flowerTopY, petalR * 0.48, 0, Math.PI * 2); ctx.fill(); + }; + const drawWeed = (x, scale = 1, tilt = 0) => { + ctx.strokeStyle = night ? "rgb(74,94,65)" : "rgb(89,118,72)"; + ctx.lineWidth = Math.max(1, this.r * 0.07 * scale); + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); ctx.moveTo(x + i * this.r * 0.06, this.r * 0.56); + ctx.quadraticCurveTo(x + this.r * (tilt + i * 0.06), this.r * 0.18, x + this.r * (tilt + i * 0.12), -this.r * (0.08 + 0.06 * Math.abs(i))); ctx.stroke(); + } + }; + + if (slaveMemorial) { + drawWeed(this.r * 0.56, 0.9, 0.04); + drawWeed(-this.r * 0.62, 0.72, -0.03); + } else if (kingMemorial) { + drawMemorialFlower(-this.r * 0.98, 0.90, -0.03, 0.03); + drawMemorialFlower(-this.r * 0.55, 1.05, 0.02, 0); + drawMemorialFlower(this.r * 0.62, 1.08, -0.02, 0); + drawMemorialFlower(this.r * 1.04, 0.90, 0.03, 0.04); + } else { + drawMemorialFlower(this.r * 0.78, 1, 0); + if (favoriteMemorial) { + drawMemorialFlower(-this.r * 0.72, 0.82, 0.035); + drawMemorialFlower(this.r * 1.02, 0.76, 0.035); + } + } } else if (this.type === "plushie") { const spriteId = "plushie_bear"; const img = getRenderableImage(spriteId, spriteId); @@ -322,6 +405,27 @@ return hit; } + StructureRegistry.register({ + type: "grave", + label: "墓", + buildNeed: "social", + buildTime: 9, + maxHp: 70, + materials: { splat: 1 }, + roles: { memorial: true }, + needEffects: { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }, + create(owner, x, y, world) { + const structure = new TarinaiStructure(this, null, x, y, world); + structure.ownerId = ""; + structure.r = 18; + structure.memorialEligibleFamilyKeys = []; + return structure; + }, + onDestroyed(structure, world, breaker) { + global.TarinaiMemorialBellSystem?.onGraveDestroyed?.(world, structure, breaker || null); + }, + }); + StructureRegistry.register({ type: "grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", diff --git a/js/system_order.js b/js/system_order.js index 30dfe45..4bb0d1b 100644 --- a/js/system_order.js +++ b/js/system_order.js @@ -16,6 +16,7 @@ { id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } }, { id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } }, { id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } }, + { id: "trap_holds", run(worldRef) { global.TarinaiItemEnvironmentHazardSystem?.enforceTrapHolds?.(worldRef); } }, { id: "seesaws", run(worldRef, ctx) { global.TarinaiSeesawSystem?.updateWorld?.(worldRef, ctx.dt); } }, { id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } }, { id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } }, @@ -25,12 +26,8 @@ { id: "diagnostics", run(worldRef) { phase("finalizeDiagnostics")?.(worldRef); } }, ]); - function update(worldRef, dt) { - const normalize = phase("normalizeDelta"); - if (!normalize) throw new Error("Tarinai simulation normalize phase is not available"); - const frameDt = normalize(worldRef, dt); - if (frameDt === null) return; - const ctx = { dt: frameDt, mobile: null }; + function runSimulationStep(worldRef, stepDt) { + const ctx = { dt: stepDt, mobile: null }; const profiler = global.TarinaiPerf; for (const system of SYSTEM_ORDER) { const end = profiler.begin(`update.phase.${system.id}`) || null; @@ -42,5 +39,39 @@ } } + const FIXED_SIMULATION_STEP = 0.025; + const MAX_FIXED_STEPS_PER_FRAME = 40; + + function update(worldRef, dt) { + const normalize = phase("normalizeDelta"); + if (!normalize) throw new Error("Tarinai simulation normalize phase is not available"); + const scaledDt = normalize(worldRef, dt); + if (scaledDt === null) return; + + // Always advance the world with one fixed simulation quantum. Previously + // slow speeds used many tiny render-frame-sized updates while fast speeds + // used fewer 0.05-second chunks. Any per-update random choice, cooldown + // edge, collision pass, or AI decision could therefore change simply by + // changing the speed control. A fixed accumulator keeps the number and + // size of simulation updates identical for the same amount of game time. + let accumulator = Math.max(0, Number(worldRef._fixedSimulationAccumulator || 0) || 0); + accumulator += Math.max(0, Number(scaledDt) || 0); + // Main-loop dt is capped and the public speed control tops out at x16, so + // this guard still covers the worst supported frame without dropping time. + let steps = 0; + while (accumulator + 1e-10 >= FIXED_SIMULATION_STEP && steps < MAX_FIXED_STEPS_PER_FRAME) { + runSimulationStep(worldRef, FIXED_SIMULATION_STEP); + accumulator -= FIXED_SIMULATION_STEP; + steps += 1; + } + // Keep a bounded remainder if an external caller supplies an unsupported + // delta. Normal gameplay never reaches this branch, but avoiding an + // unbounded backlog is safer than a permanent catch-up spiral. + if (steps >= MAX_FIXED_STEPS_PER_FRAME && accumulator >= FIXED_SIMULATION_STEP) { + accumulator = Math.min(accumulator, FIXED_SIMULATION_STEP * 0.999999); + } + worldRef._fixedSimulationAccumulator = Math.max(0, accumulator); + } + global.TarinaiSystemOrder = Object.freeze({ update }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index 8f389e6..648cbca 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -97,6 +97,7 @@ class Tarinai { constructor(world, opts = {}) { this.affection = opts.affection ?? rand(0, 30); this.dead = !!opts.dead; this.deathReason = opts.deathReason || ""; + this.deathTime = opts.deathTime != null && Number.isFinite(Number(opts.deathTime)) ? Number(opts.deathTime) : null; this.lastDamageCause = opts.lastDamageCause || ""; this.lastDamageAmount = opts.lastDamageAmount ?? 0; this.lastDamageAt = opts.lastDamageAt ?? -999; diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js index 3ef6eec..723227e 100644 --- a/js/tarinai_behavior_text.js +++ b/js/tarinai_behavior_text.js @@ -64,8 +64,7 @@ function actionTextForReason(action = null, tarinai = null) { } function isEffectCauseText(text = "") { - return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text) - ?? globalThis.isTarinaiEffectCauseText?.(text)); + return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text)); } function forcedCausePhrase(source = "", fallback = "") { diff --git a/js/tarinai_consumable_behavior.js b/js/tarinai_consumable_behavior.js index b4a15b3..62af173 100644 --- a/js/tarinai_consumable_behavior.js +++ b/js/tarinai_consumable_behavior.js @@ -26,6 +26,7 @@ function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { if (!t || !world || !target || target.dead) return false; const foodType = effectiveFoodTypeForItem(target); + if (t.isZunchiSlave && foodType === "sweet") return false; const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269"; const effectiveDt = Math.max(0.10, Number(dt) || 0.12); if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) { diff --git a/js/tarinai_contact_item_system.js b/js/tarinai_contact_item_system.js index 6f94821..0558fc9 100644 --- a/js/tarinai_contact_item_system.js +++ b/js/tarinai_contact_item_system.js @@ -107,7 +107,31 @@ const d = contact.distance; } + function updateResidues(tarinai, dt) { + const worldRef = tarinai?.world; + if (!worldRef?.nearbyResidues) return false; + let touched = false; + for (const residue of worldRef.nearbyResidues(tarinai.x, tarinai.y, (tarinai.radius || 22) + 58) || []) { + if (!residue || residue.dead) continue; + const d = distXY(tarinai.x, tarinai.y, residue.x, residue.y); + if (residue.type === "splat") { + const touch = clamp(1 - d / ((tarinai.radius || 22) + (residue.r || 26) + 16), 0, 1); + if (touch > 0) { + applyNeedShock(tarinai, { safety: touch * dt * 5, health: touch * dt * 3 }); + if (touch > 0.06) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); + touched = true; + } + } else if (residue.type === "trace" && d <= (tarinai.radius || 22) + (residue.r || 16) + 14) { + tarinai.loneliness -= dt * 0.25; + applyNeedShock(tarinai, { social: -dt * 1, safety: dt * 0.8 }); + touched = true; + } + } + return touched; + } + global.TarinaiContactItemSystem = Object.freeze({ updateCandidate, + updateResidues, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_direct_feeding_system.js b/js/tarinai_direct_feeding_system.js index 2d61e66..f7d4f89 100644 --- a/js/tarinai_direct_feeding_system.js +++ b/js/tarinai_direct_feeding_system.js @@ -109,6 +109,7 @@ if (!worldRef || !tarinai || tarinai.dead || !hasConsumableAmount(item)) return null; if (typeof global.consumeBehaviorTarget !== "function") return null; const type = itemType(item); + if (tarinai.isZunchiSlave && type === "sweet") return null; const label = typeof global.toolLabel === "function" ? global.toolLabel(type) : type; const healthNeedBefore = Number(tarinai.needs?.health); const consumed = global.consumeBehaviorTarget(tarinai, worldRef, item, roleFor(item), 0.18); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index b24e155..2efbcdc 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -290,22 +290,16 @@ let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; const now = this.world?.time || 0; if (!alreadyInside && occupants.length >= capacity) { - const kingSleepDisplace = Boolean(this.isTarinaiChampion && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping)); - if (options?.forceDisplace || kingSleepDisplace) { - const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion) - || occupants.find(t => t && t !== this && !t.dead); - if (victim) { - victim.leaveNestBox?.(box); - victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8); - victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22); - victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`; - victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65); - victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`); - if (this.isTarinaiChampion) { - this.world?.log?.(`${this.name}\u304c${containerLabel(box)}\u306b\u5165\u308a\u3001${victim.name}\u3092\u62bc\u3057\u51fa\u3057\u305f\u3002`, "event", { participants: [this, victim] }); - } - occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim); - } + const victim = globalThis.TarinaiNestSleepSystem?.lowerPriorityNestOccupant?.(this.world, box, this, occupants) || null; + if (victim) { + victim.leaveNestBox?.(box); + victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8); + victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22); + victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`; + victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65); + victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`); + this.world?.log?.(`${this.name}\u304c${containerLabel(box)}\u306b\u5165\u308a\u3001${victim.name}\u3092\u62bc\u3057\u51fa\u3057\u305f\u3002`, "event", { participants: [this, victim] }); + occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim); } if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false; } diff --git a/js/tarinai_food_interaction_system.js b/js/tarinai_food_interaction_system.js index 724f04a..e1c0eaa 100644 --- a/js/tarinai_food_interaction_system.js +++ b/js/tarinai_food_interaction_system.js @@ -12,6 +12,7 @@ const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai if (!contact) return false; const d = contact.distance; const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type; +if (tarinai.isZunchiSlave && foodType === "sweet") return false; const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi"; if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && tarinai.foodItemHasServingLeft(it)) { diff --git a/js/tarinai_forced_behavior.js b/js/tarinai_forced_behavior.js index 19e80b8..c0c55e1 100644 --- a/js/tarinai_forced_behavior.js +++ b/js/tarinai_forced_behavior.js @@ -145,6 +145,9 @@ function clearForcedBehaviorQueue(tarinai, predicate = null) { } function behaviorText(tarinai) { + if (tarinai?.state === "build_memorial") return "親しいたりないの墓を作っている。"; + if (tarinai?.state === "mourn_grave") return "親しいたりないの墓が壊されて泣いている。"; + if (tarinai?.state === "bell_call") return "呼び鈴の鳴った場所に集まっている。"; const b = currentTarinaiBehavior(tarinai); if (!b) return ""; const state = String(tarinai.state || ""); diff --git a/js/tarinai_item_interaction_system.js b/js/tarinai_item_interaction_system.js index d63a73e..d5585b3 100644 --- a/js/tarinai_item_interaction_system.js +++ b/js/tarinai_item_interaction_system.js @@ -18,6 +18,7 @@ } global.TarinaiContactItemSystem.updateCandidate(this, ctx, item, dt); } + global.TarinaiContactItemSystem.updateResidues?.(this, dt); global.TarinaiItemInteractionContext.finalize(this); return true; } diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index 33ef48a..abfc987 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -73,6 +73,8 @@ function foodPriorityRank(tarinai, item) { const need = getTarinaiBehaviorNeed(tarinai); const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; + if (tarinai?.isZunchiSlave && type === "sweet") return -1; + if (tarinai?.isZunchiSlave && type === "zunchi") return 6200; if (type === "sweet") return 4300 + Math.min(120, Math.max(0, healthNeed - 40) * 3); if (type === "water") return 3650 + Math.max(0, healthNeed - 42) * 7; if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; @@ -106,6 +108,7 @@ function foodPriorityScore(tarinai, item, distance = 0) { function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { if (!tarinai || !world) return false; + if (tarinai.isZunchiSlave) return false; const source = world.nearbyFood?.(tarinai.x, tarinai.y, maxDist, true) || world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || []; for (const it of source) { if (!it || it.dead) continue; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index ad21ec1..c6040f3 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -894,6 +894,7 @@ function resolveNeedsCore(dt) { if (this.dead) return this.variantSprite("fear"); if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) return this.sunbathSpriteId ? this.sunbathSpriteId() : "sunbath_1"; if (this.sleepDisease) return this.variantSprite("sleep"); + if (Number(this._bearTrapHoldUntil || 0) > Number(this.world?.time || 0)) return this.variantSprite("hurt"); if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); const low = this.lowHealthSprite(); if (low) return low; @@ -945,7 +946,8 @@ function resolveNeedsCore(dt) { const next = this.rawSpriteId(); const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04; const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04; - const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate"); + const trappedNow = Number(this._bearTrapHoldUntil || 0) > now; + const forceImmediate = trappedNow || String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate"); if (!this.visibleSpriteId || forceImmediate) { this.visibleSpriteId = next; this.spriteLockUntil = forceImmediate ? now : now + 1.0; @@ -1124,8 +1126,9 @@ function resolveNeedsCore(dt) { bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) { if (!options.force && this.world.time < this.nextBubbleAt) return false; + const spawned = this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); + if (!spawned) return false; this.nextBubbleAt = this.world.time + cooldown; - this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); return true; }, diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index 6856850..c153ff4 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -73,6 +73,50 @@ return true; } + + function nestOccupantPriority(tarinai) { + if (!tarinai) return -1; + if (tarinai.isTarinaiChampion) return 2; + if (tarinai.isZunchiSlave) return 0; + return 1; + } + + function lowerPriorityNestOccupant(worldRef, box, entrant, occupants = null) { + const entrantPriority = nestOccupantPriority(entrant); + const list = Array.isArray(occupants) ? occupants : nestBoxOccupants(worldRef, box, Infinity); + let best = null; + let bestPriority = Infinity; + for (const occupant of list || []) { + if (!occupant || occupant.dead || occupant === entrant) continue; + const priority = nestOccupantPriority(occupant); + if (priority >= entrantPriority) continue; + if (priority < bestPriority) { + best = occupant; + bestPriority = priority; + } + } + return best; + } + + function lowerPriorityNestReservation(worldRef, box, entrant) { + if (!worldRef || !box || !entrant) return null; + const now = Number(worldRef.time || 0) || 0; + const entrantPriority = nestOccupantPriority(entrant); + let best = null; + let bestPriority = Infinity; + for (const candidate of worldRef.tarinai || []) { + if (!candidate || candidate.dead || candidate === entrant) continue; + if (!tarinaiIsReservingNest(candidate, box, now)) continue; + const priority = nestOccupantPriority(candidate); + if (priority >= entrantPriority) continue; + if (priority < bestPriority) { + best = candidate; + bestPriority = priority; + } + } + return best; + } + function nestBoxReservationCount(worldRef, box) { if (!worldRef || !box || box.dead || !isNestContainer(box)) return 0; const now = Number(worldRef.time || 0) || 0; @@ -113,8 +157,11 @@ if (sleepTargetBlocked(worldRef, tarinai, item)) return false; const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1; const occupancy = bedOccupancy(worldRef, item); - if (tarinai?.isTarinaiChampion && item.type === "nest_box") return true; - return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id); + if (tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id)) return true; + if (occupancy < capacity) return true; + if (!isNestContainer(item)) return false; + const occupants = nestBoxOccupants(worldRef, item, Infinity); + return Boolean(lowerPriorityNestOccupant(worldRef, item, tarinai, occupants) || lowerPriorityNestReservation(worldRef, item, tarinai)); } function findStructure(worldRef, tarinai, opts = {}) { @@ -195,5 +242,7 @@ nearestSleepPlaceOfType, findPrioritySleepPlaceBeforeGrassBedBuild, findNearestSleepPlace, + nestOccupantPriority, + lowerPriorityNestOccupant, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index b24e4b3..6b26cf0 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -249,10 +249,13 @@ function tarinaiSpriteReversesFacing(sprite) { const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0; const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0; const coldStatus = this.temperatureStatus || this.world?.temperatureStatusFor?.(this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15), this) || null; + const trappedShakeActive = Number(this._bearTrapHoldUntil || 0) > Number(this.world?.time || 0); const coldShakeActive = Boolean(coldStatus && coldStatus.direction === "cold" && (coldStatus.discomfort || 0) > 0.5 && this.state !== "sunbath"); const coldShakeStrength = coldShakeActive ? clamp((coldStatus.discomfort || 0) / 12, 0.25, 1.25) : 0; - const coldShakeX = coldShakeActive ? (Math.sin(t * 46 + this.age * 0.9) + Math.sin(t * 71 + this.age * 0.31) * 0.55) * this.radius * 0.035 * coldShakeStrength : 0; - const coldShakeY = coldShakeActive ? Math.sin(t * 58 + this.age * 0.47) * this.radius * 0.012 * coldShakeStrength : 0; + const trapShakeStrength = trappedShakeActive ? 1.05 : 0; + const shakeStrength = Math.max(coldShakeStrength, trapShakeStrength); + const coldShakeX = (coldShakeActive || trappedShakeActive) ? (Math.sin(t * 46 + this.age * 0.9) + Math.sin(t * 71 + this.age * 0.31) * 0.55) * this.radius * 0.035 * shakeStrength : 0; + const coldShakeY = (coldShakeActive || trappedShakeActive) ? Math.sin(t * 58 + this.age * 0.47) * this.radius * 0.012 * shakeStrength : 0; const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25; const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); @@ -467,17 +470,19 @@ function tarinaiSpriteReversesFacing(sprite) { ctx.restore(); } - if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { + const cursorLove = Math.max(0, this.cursorPetting || 0); + const cursorPettingActive = cursorLove > 0.035; + if (showDecor && this.state !== "sleep" && (cursorPettingActive || ((this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44))) { ctx.save(); - const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0; - ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; - ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); - const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2; + const showPettingHearts = cursorPettingActive || this.state === "cursor_friend"; + ctx.globalAlpha = showPettingHearts ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; + ctx.fillStyle = showPettingHearts ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); + const count = showPettingHearts ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2; for (let i = 0; i < count; i++) { const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05; const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit; const py = -drawH * (0.22 + (i % 2) * 0.06) + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * (0.08 + cursorLove * 0.02); - if (this.state === "cursor_friend") { + if (showPettingHearts) { const s = Math.max(5, drawW * (0.070 + (i % 3) * 0.012 + cursorLove * 0.01)); drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 }); } else { diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 02a4330..373dbf1 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -25,7 +25,7 @@ const ROUTE_STATES = new Set([ "seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature", - "wander", "panic", + "wander", "panic", "bell_call", "build_memorial", "visit_grave", ]); function isNestContainerItem(item) { @@ -429,6 +429,8 @@ move(dt) { if (!(this.burning || (this.burnTimer || 0) > 0.02) && this.freezeSleepDiseaseMotion(dt)) return; + const specialMotion = global.TarinaiMemorialBellSystem?.updateMotion?.(this, dt); + if (specialMotion?.handled) return; let ax = 0, ay = 0; const itemSpeedMul = this.itemSpeedMultiplier ? this.itemSpeedMultiplier() : 1; const baseSpeed = 46.5 * 1.5 * this.trait.speed * itemSpeedMul * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); @@ -831,6 +833,7 @@ const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason, opts) : (reason || "\u4e8b\u6545"); this.dead = true; this.deathReason = finalReason; + this.deathTime = Number(this.world?.time || 0) || 0; this.world.syncTarinaiCountEntry?.(this); this.world.deadCount += 1; globalThis.TarinaiAchievements?.evaluateEvent?.(this.world, "population", { tarinai: this, delta: -1, reason: finalReason }); @@ -850,8 +853,11 @@ this.world.drawListDirty = true; this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup"); this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup"); - this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace"); - this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat"); + this.world.addResidue?.("trace", this.x, this.y, { reason: "tarinai-death-trace" }); + // Favorite Tarinai are memorialized immediately and therefore do not leave + // the splat resource that normal graves are built from. + if (!this.favorite) this.world.addResidue?.("splat", this.x, this.y, { reason: "tarinai-death-splat" }); + this.world.markTerrainDirtyAt?.(this.x, this.y, Math.max(42, (this.radius || 22) * 2.1), "tarinai-death-remains"); if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) { const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); if (spot) { diff --git a/js/tarinai_update_step_ai.js b/js/tarinai_update_step_ai.js index 446d60d..c63d972 100644 --- a/js/tarinai_update_step_ai.js +++ b/js/tarinai_update_step_ai.js @@ -29,6 +29,10 @@ // and can over-amplify safety/fear transitions when updates are sparse. const needStepDt = urgentAi ? 0.50 : 1.00; t.resolveNeeds(needStepDt); + if (global.TarinaiMemorialBellSystem?.updateAi?.(t, scanDt)) { + t.updateFacing(); + return { done: false, special: true }; + } const sleepingNow = t.state === "sleep"; if (!sleepingNow) { t.reactToFreshZunchi(scanDt); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index 1cfbeb9..eef906a 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -236,7 +236,7 @@ t._lastPhysicalVelocityY = 0; return { done: false, held: true }; } - const motionDtCap = context.offscreenCatchup ? 0.75 : 0.05; + const motionDtCap = context.offscreenCatchup ? 2.4 : 0.05; const motionDt = Math.max(0.001, Math.min(motionDtCap, Number(context.motionDt ?? dt) || 0.016)); if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { return passivePhysicalMotion(t, motionDt, context); diff --git a/js/text_catalog.js b/js/text_catalog.js index 382b3bb..dccb259 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -69,6 +69,10 @@ TEXT_CATALOG.stateLabels = Object.freeze({ ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d", ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d", frozen: "\u51b7\u51cd\u4e2d", + bell_call: "呼び鈴へ移動中", + build_memorial: "墓を作っている", + visit_grave: "墓参りをしている", + mourn_grave: "墓が壊されて泣いている", }); diff --git a/js/ui.js b/js/ui.js index 695d6eb..11a565c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -4,7 +4,10 @@ const canvas = document.getElementById("gameCanvas"); const ctx = canvas.getContext("2d"); const ui = { pauseBtn: document.getElementById("pauseBtn"), - speedBtn: document.getElementById("speedBtn"), + speedSelect: document.getElementById("speedSelect"), + speedMenu: document.getElementById("speedMenu"), + speedMenuBtn: document.getElementById("speedMenuBtn"), + speedMenuPanel: document.getElementById("speedMenuPanel"), topMenu: document.getElementById("topMenu"), topMenuBtn: document.getElementById("topMenuBtn"), topMenuPanel: document.getElementById("topMenuPanel"), @@ -28,6 +31,7 @@ const ui = { selectedCard: document.getElementById("selectedCard"), selectedCloseBtn: document.getElementById("selectedCloseBtn"), colonyChartTabs: document.getElementById("colonyChartTabs"), + colonyChartRangeTabs: document.getElementById("colonyChartRangeTabs"), selectedInfo: document.getElementById("selectedInfo"), log: document.getElementById("log"), logPushControls: document.getElementById("logPushControls"), @@ -99,10 +103,12 @@ const uiCache = { chartBucketStart: -Infinity, chartBucketEnd: -Infinity, chartBucketAccum: null, + chartNextSampleAt: 0, chartBirthEventSeen: null, chartDeathEventSeen: null, lastChartDraw: "", chartMode: "population", + chartRange: "season", chartHiddenSeries: new Set(["ants"]), canvasDpr: 0, panning: false, diff --git a/js/ui_bind.js b/js/ui_bind.js index 3cd3de2..e495b71 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -408,6 +408,16 @@ function bindUI() { uiCache.lastChartDraw = ""; renderStats(); }); + ui.colonyChartRangeTabs?.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-chart-range]"); + if (!btn) return; + uiCache.chartRange = btn.dataset.chartRange || "season"; + for (const b of ui.colonyChartRangeTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn); + uiCache.lastChartDraw = ""; + audio.uiClick?.(); + renderStats({ force: true }); + }); + window.bindColonyChartHover?.(); const toggleChartSeries = (target) => { const control = target?.closest?.("[data-series-key]"); if (!control) return false; @@ -469,6 +479,40 @@ function bindUI() { }, { passive: false }); }; bindArchiveHorizontal(ui.archiveContent); + const clearFamilyHoverHighlight = (tree) => { + if (!tree) return; + tree.classList.remove("family-hover-active"); + for (const el of tree.querySelectorAll(".tree-node.family-hover-dim, .tree-node.family-hover-focus, .tree-node.family-hover-parent, .tree-node.family-hover-child")) { + el.classList.remove("family-hover-dim", "family-hover-focus", "family-hover-parent", "family-hover-child"); + } + }; + ui.archiveContent?.addEventListener("mouseover", (e) => { + const node = e.target.closest(".lineage-family-tree [data-family-tarinai-id]"); + if (!node || node.contains(e.relatedTarget)) return; + const tree = node.closest(".lineage-family-tree"); + if (!tree) return; + clearFamilyHoverHighlight(tree); + const id = String(node.getAttribute("data-family-tarinai-id") || ""); + const familyNode = world.family?.[id] || null; + const parentIds = new Set((familyNode?.parents || []).map(String)); + const childIds = new Set((familyNode?.children || []).map(String)); + tree.classList.add("family-hover-active"); + for (const el of tree.querySelectorAll(".tree-node[data-family-tarinai-id]")) { + const otherId = String(el.getAttribute("data-family-tarinai-id") || ""); + el.classList.add("family-hover-dim"); + if (otherId === id) el.classList.add("family-hover-focus"); + else if (parentIds.has(otherId)) el.classList.add("family-hover-parent"); + else if (childIds.has(otherId)) el.classList.add("family-hover-child"); + } + }); + ui.archiveContent?.addEventListener("mouseout", (e) => { + const node = e.target.closest(".lineage-family-tree [data-family-tarinai-id]"); + if (!node || node.contains(e.relatedTarget)) return; + const tree = node.closest(".lineage-family-tree"); + const enteringAnotherNode = e.relatedTarget?.closest?.(".lineage-family-tree [data-family-tarinai-id]"); + if (enteringAnotherNode && enteringAnotherNode.closest(".lineage-family-tree") === tree) return; + clearFamilyHoverHighlight(tree); + }); ui.archiveContent?.addEventListener("click", (e) => { const node = e.target.closest("[data-family-tarinai-id]"); if (!node) return; @@ -570,12 +614,37 @@ function bindUI() { }); ui.topMenuPanel?.addEventListener("click", (e) => e.stopPropagation()); - ui.speedBtn?.addEventListener("click", () => { + function closeSpeedMenu() { + ui.speedMenuPanel?.classList.add("hidden"); + ui.speedMenuBtn?.setAttribute("aria-expanded", "false"); + } + function toggleSpeedMenu() { + const opening = Boolean(ui.speedMenuPanel?.classList.contains("hidden")); + closeTopMenu(); + ui.speedMenuPanel?.classList.toggle("hidden", !opening); + ui.speedMenuBtn?.setAttribute("aria-expanded", opening ? "true" : "false"); + } + ui.speedMenuBtn?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); audio.uiClick?.(); - window.TarinaiCommands.dispatch(world, { type: "simulation.speed.cycle", speeds: [1, 2, 4] }); + toggleSpeedMenu(); + }); + ui.speedMenuPanel?.addEventListener("click", (e) => { + e.stopPropagation(); + const option = e.target.closest?.("[data-speed-value]"); + if (!option) return; + const speed = Number(option.dataset.speedValue); + if (ui.speedSelect) ui.speedSelect.value = String(speed); + window.TarinaiCommands.dispatch(world, { type: "simulation.speed.set", speed, allowed: [0.5, 1, 2, 4, 8, 16] }); + closeSpeedMenu(); syncTopButtons(); }); - ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); openEcologyDialog(); }); + ui.speedSelect?.addEventListener("change", () => { + window.TarinaiCommands.dispatch(world, { type: "simulation.speed.set", speed: Number(ui.speedSelect.value), allowed: [0.5, 1, 2, 4, 8, 16] }); + syncTopButtons(); + }); + ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); closeSpeedMenu(); closeTopMenu(); openEcologyDialog(); }); ui.soundBtn?.addEventListener("click", (e) => { e.stopPropagation(); closeVisualSettingsPanel(); @@ -595,6 +664,7 @@ function bindUI() { closeVisualSettingsPanel(); if (!ui.topMenu?.contains(target)) closeTopMenu(); } + if (!ui.speedMenu?.contains(target)) closeSpeedMenu(); // Normal controls keep the selected tool. A blank UI click exits placement // mode; canvas clicks are still handled as tool actions. if (isActiveToolMode() && !canvas?.contains?.(target) && !isInteractiveUiTarget(target)) { diff --git a/js/ui_charts.js b/js/ui_charts.js index 0dc257c..22287ca 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -15,7 +15,7 @@ function bindColonyMoodTooltip() { } function colonyChartObjectKeys() { - return ["grass", "zunchi", "trace", "grass_bed", "water"]; + return ["grass", "zunchi", "trace", "grave", "grass_bed", "water"]; } function colonyStatsUiVisible() { @@ -26,7 +26,15 @@ function colonyStatsUiVisible() { return true; } -function collectDynamicColonyStats() { +function collectDynamicColonyStats(options = {}) { + const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + const cache = world._colonyStatsDynamicCache; + const cacheAge = now - (Number(world._colonyStatsDynamicCacheAt) || 0); + // Colony cards can ask for a refresh from many UI paths. Reuse one aggregate + // for a short real-time window instead of rescanning every Tarinai each time. + // Fixed history samples opt into force=true so their recorded values remain + // tied to the requested simulation timestamp. + if (options.force !== true && cache && cacheAge >= 0 && cacheAge < 800) return cache; const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const needSums = Object.fromEntries(needKeys.map(key => [key, 0])); let liveCount = 0; @@ -54,6 +62,7 @@ function collectDynamicColonyStats() { ...Object.fromEntries(needKeys.map(key => [`need_${key}`, liveCount ? needSums[key] / divisor : 0])), }; world._colonyStatsDynamicCache = dynamic; + world._colonyStatsDynamicCacheAt = now; return dynamic; } @@ -83,6 +92,13 @@ function renderStats(options = {}) { stress: dynamic.stress || 0, temperature: Number(temp) || 0, ants: dynamic.ants || 0, + graves: (() => { + const bucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null; + if (bucket && typeof bucket.length === "number") return Math.max(0, bucket.length); + let count = 0; + for (const item of world.items || []) if (item && !item.dead && item.type === "grave") count += 1; + return count; + })(), ...dynamic, objects: world.itemCounts || {}, }; @@ -113,126 +129,143 @@ function renderStats(options = {}) { bindColonyMoodTooltip(); window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood); window.TarinaiGroundUI.update(world); - updateColonyHistory(values); drawColonyChart(values); if (ui?.selectedCard && !ui.selectedCard.classList.contains("hidden")) renderSelected(); if (world.familyTreeDirty) scheduleArchiveWindowRender(); } -function updateColonyHistory(values) { - const t = Math.max(0, world.time || 0); +function updateColonyHistory(values, options = {}) { + const t = Math.max(0, Number(world.time || 0) || 0); const history = uiCache.chartHistory; const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); - const maxPoints = 5 * 12; - const currentBirthEvents = Math.max(0, Number(values.birthEvents || 0) || 0); - const currentDeathEvents = Math.max(0, Number(values.deathEvents || 0) || 0); - const eventCountersRewound = (Number.isFinite(uiCache.chartBirthEventSeen) && currentBirthEvents < uiCache.chartBirthEventSeen) - || (Number.isFinite(uiCache.chartDeathEventSeen) && currentDeathEvents < uiCache.chartDeathEventSeen); - if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart) || eventCountersRewound) { + const maxPoints = 20 * 12; + const birthsTotal = Math.max(0, Number(values.birthEvents || 0) || 0); + const deathsTotal = Math.max(0, Number(values.deathEvents || 0) || 0); + const lastT = history.length ? Number(history[history.length - 1]?.t || 0) : -Infinity; + const rewound = t + 1e-6 < lastT + || (Number.isFinite(uiCache.chartNextSampleAt) && t + interval < uiCache.chartNextSampleAt) + || (Number.isFinite(uiCache.chartBirthEventSeen) && birthsTotal < uiCache.chartBirthEventSeen) + || (Number.isFinite(uiCache.chartDeathEventSeen) && deathsTotal < uiCache.chartDeathEventSeen); + if (rewound) { history.length = 0; - uiCache.chartBucketStart = -Infinity; - uiCache.chartBucketEnd = -Infinity; - uiCache.chartBucketAccum = null; + uiCache.chartNextSampleAt = 0; uiCache.chartBirthEventSeen = null; uiCache.chartDeathEventSeen = null; uiCache.lastChartDraw = ""; } + if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0; + if (options.force !== true && t + 1e-6 < uiCache.chartNextSampleAt) return false; + if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = birthsTotal; + if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = deathsTotal; + const births = Math.max(0, birthsTotal - uiCache.chartBirthEventSeen); + const deaths = Math.max(0, deathsTotal - uiCache.chartDeathEventSeen); + uiCache.chartBirthEventSeen = birthsTotal; + uiCache.chartDeathEventSeen = deathsTotal; const objectKeys = colonyChartObjectKeys(); const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; - const makeBucket = (start) => ({ - start, - end: start + interval, - count: 0, - sums: { - pop: 0, - zunchiSlaves: 0, - tarinaiKings: 0, - sick: 0, - juvenile: 0, - elder: 0, - ants: 0, - stress: 0, - temperature: 0, - ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), - }, - objects: Object.fromEntries(objectKeys.map(k => [k, 0])), - events: { births: 0, deaths: 0 }, - }); - if (!uiCache.chartBucketAccum) { - const start = Math.floor(t / interval) * interval; - uiCache.chartBucketStart = start; - uiCache.chartBucketEnd = start + interval; - uiCache.chartBucketAccum = makeBucket(start); - } - const pushBucket = (bucket) => { - if (!bucket || bucket.count <= 0) return; - const row = { - t: bucket.end, - pop: bucket.sums.pop / bucket.count, - zunchiSlaves: bucket.sums.zunchiSlaves / bucket.count, - tarinaiKings: bucket.sums.tarinaiKings / bucket.count, - sick: bucket.sums.sick / bucket.count, - juvenile: bucket.sums.juvenile / bucket.count, - elder: bucket.sums.elder / bucket.count, - ants: bucket.sums.ants / bucket.count, - stress: bucket.sums.stress / bucket.count, - temperature: bucket.sums.temperature / bucket.count, - ...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])), - ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), - births: Math.max(0, Number(bucket.events?.births || 0) || 0), - deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0), - }; - history.push(row); - if (history.length > maxPoints) history.splice(0, history.length - maxPoints); + const sampleT = Math.max(0, Math.floor(t / interval) * interval); + const row = { + t: sampleT, + pop: values.pop || 0, + zunchiSlaves: values.zunchiSlaves || 0, + tarinaiKings: values.tarinaiKings || 0, + sick: values.sick || 0, + juvenile: values.juvenile || 0, + elder: values.elder || 0, + ants: values.ants || 0, + stress: values.stress || 0, + temperature: Number(values.temperature || 0) || 0, + ...Object.fromEntries(needKeys.map(k => [`need_${k}`, values[`need_${k}`] || 0])), + ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, k === "grave" ? (values.graves || 0) : ((values.objects || {})[k] || 0)])), + births, + deaths, }; - while (t >= uiCache.chartBucketEnd) { - const b = uiCache.chartBucketAccum; - pushBucket(b); - uiCache.chartBucketStart = uiCache.chartBucketEnd; - uiCache.chartBucketEnd = uiCache.chartBucketStart + interval; - uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart); + if (history.length && Math.abs((Number(history[history.length - 1]?.t) || 0) - sampleT) < 1e-6) history[history.length - 1] = row; + else history.push(row); + if (history.length > maxPoints) history.splice(0, history.length - maxPoints); + uiCache.chartNextSampleAt = sampleT + interval; + return true; +} + +function collectColonyHistorySampleValues() { + const counts = world.tarinaiCounts?.() || world.rebuildTarinaiCountCache?.("chart-sample") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 }; + const dynamic = collectDynamicColonyStats({ force: true }); + const graveBucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null; + let graves = 0; + if (graveBucket && typeof graveBucket.length === "number") graves = Math.max(0, graveBucket.length); + else for (const item of world.items || []) if (item && !item.dead && item.type === "grave") graves += 1; + return { + pop: Math.max(0, Number(counts.alive || 0) || 0), + zunchiSlaves: Math.max(0, Number(counts.zunchiSlaves || 0) || 0), + tarinaiKings: Math.max(0, Number(counts.tarinaiKings || 0) || 0), + sick: dynamic.sick || 0, + juvenile: dynamic.juvenile || 0, + elder: dynamic.elder || 0, + ants: dynamic.ants || 0, + stress: dynamic.stress || 0, + temperature: Number(world.currentTemperature ?? world.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15)) || 0, + birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0), + deathEvents: Math.max(0, Number(world.deadCount || 0) || 0), + graves, + ...dynamic, + objects: world.itemCounts || {}, + }; +} + +function tickColonyHistorySampling() { + const t = Math.max(0, Number(world?.time || 0) || 0); + const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); + if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0; + if (t + 1e-6 < uiCache.chartNextSampleAt) return false; + return updateColonyHistory(collectColonyHistorySampleValues(), { force: true }); +} + +function chartRangeConfig() { + const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); + const range = ["day", "season", "year"].includes(uiCache.chartRange) ? uiCache.chartRange : "season"; + if (range === "day") return { id: range, duration: dayLength, footer: "\u0032\u6642\u9593\u3054\u3068 / 1\u65e5" }; + if (range === "year") return { id: range, duration: dayLength * 20, footer: "1\u65e51\u70b9 / 20\u65e5\u9593" }; + return { id: "season", duration: dayLength * 5, footer: "\u0032\u6642\u9593\u3054\u3068 / 5\u65e5\u9593" }; +} + +function yearRepresentativeRows(rows, minT, maxT) { + const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); + const grouped = new Map(); + for (const row of rows || []) { + const t = Math.max(0, Number(row?.t || 0) || 0); + if (t < minT - 1e-6 || t > maxT + 1e-6) continue; + const day = Math.floor(t / dayLength); + const noon = day * dayLength + dayLength * 0.5; + const prev = grouped.get(day); + if (!prev || Math.abs(t - noon) < Math.abs((Number(prev.t || 0) || 0) - noon)) grouped.set(day, row); } - const bucket = uiCache.chartBucketAccum; - if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = currentBirthEvents; - if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = currentDeathEvents; - bucket.events.births += Math.max(0, currentBirthEvents - uiCache.chartBirthEventSeen); - bucket.events.deaths += Math.max(0, currentDeathEvents - uiCache.chartDeathEventSeen); - uiCache.chartBirthEventSeen = currentBirthEvents; - uiCache.chartDeathEventSeen = currentDeathEvents; - bucket.count += 1; - bucket.sums.pop += values.pop || 0; - bucket.sums.zunchiSlaves += values.zunchiSlaves || 0; - bucket.sums.tarinaiKings += values.tarinaiKings || 0; - bucket.sums.sick += values.sick || 0; - bucket.sums.juvenile += values.juvenile || 0; - bucket.sums.elder += values.elder || 0; - bucket.sums.ants += values.ants || 0; - bucket.sums.stress += values.stress || 0; - bucket.sums.temperature += Number(values.temperature || 0) || 0; - for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0; - for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0; + return Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]).map(([, row]) => row); } function chartRows(values) { - // The colony chart is sampled in two-hour buckets. Do not append a live - // preview row here; it makes the graph redraw every frame even though the - // recorded statistics have not advanced yet. - if (uiCache.chartHistory.length) return uiCache.chartHistory.slice(); + const allRows = uiCache.chartHistory.length ? uiCache.chartHistory.slice() : []; + const now = Math.max(0, Number(world.time || 0) || 0); + const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); + // Keep the visible time window anchored to the newest recorded sample rather + // than the continuously advancing world clock. The graph now shifts left at + // exactly the same moment a new data point is appended. + const maxT = allRows.length + ? Math.max(0, Number(allRows[allRows.length - 1]?.t || 0) || 0) + : Math.max(0, Math.floor(now / interval) * interval); + const config = chartRangeConfig(); + const minT = Math.max(0, maxT - config.duration); + let rows = allRows.filter(row => { + const t = Math.max(0, Number(row?.t || 0) || 0); + return t >= minT - 1e-6 && t <= maxT + 1e-6; + }); + if (config.id === "year") rows = yearRepresentativeRows(rows, minT, maxT); + if (rows.length) return rows; const objectKeys = colonyChartObjectKeys(); const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; return [{ - t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0), - pop: 0, - zunchiSlaves: 0, - tarinaiKings: 0, - sick: 0, - juvenile: 0, - elder: 0, - ants: 0, - stress: 0, - temperature: 0, - births: 0, - deaths: 0, + t: now, + pop: 0, zunchiSlaves: 0, tarinaiKings: 0, sick: 0, juvenile: 0, elder: 0, ants: 0, + stress: 0, temperature: 0, births: 0, deaths: 0, ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])), }]; @@ -247,6 +280,119 @@ function chartSeasonDayLabelForTime(t = 0) { return `${season}${seasonDay}\u65e5`; } +function chartDateTimeLabelForTime(t = 0) { + const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); + const safe = Math.max(0, Number(t || 0) || 0); + const dayStart = Math.floor(safe / dayLength) * dayLength; + const dayRatio = Math.max(0, Math.min(0.999999, (safe - dayStart) / dayLength)); + const totalMinutes = Math.floor(dayRatio * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${chartSeasonDayLabelForTime(safe)} ${hh}:${mm}`; +} + +function bindColonyChartHover() { + const chart = ui?.colonyChart; + if (!chart || chart.dataset.historyHoverBound === "1") return; + chart.dataset.historyHoverBound = "1"; + let tooltip = document.getElementById("colonyChartFloatingTooltip"); + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.id = "colonyChartFloatingTooltip"; + tooltip.className = "colony-chart-tooltip hidden"; + document.body.appendChild(tooltip); + } + const hoverMarker = () => { + let marker = chart.querySelector(".colony-chart-hover-point"); + if (!marker) { + marker = document.createElement("div"); + marker.className = "colony-chart-hover-point hidden"; + chart.appendChild(marker); + } + return marker; + }; + const hide = () => { + tooltip.classList.add("hidden"); + hoverMarker().classList.add("hidden"); + }; + chart.addEventListener("mouseleave", hide); + chart.addEventListener("mousemove", (event) => { + const state = uiCache.chartHoverState; + if (!state?.rows?.length) return hide(); + const rect = chart.getBoundingClientRect(); + if (!rect.width || !rect.height) return hide(); + const px = Math.max(0, Math.min(state.cssW, (event.clientX - rect.left) * state.cssW / rect.width)); + const py = Math.max(0, Math.min(state.cssH, (event.clientY - rect.top) * state.cssH / rect.height)); + const plotX = Math.max(state.pad.l, Math.min(state.pad.l + state.w, px)); + const ratio = (plotX - state.pad.l) / Math.max(1, state.w); + const targetT = state.minT + ratio * Math.max(1, state.maxT - state.minT); + let nearest = state.rows[0]; + let best = Infinity; + for (const row of state.rows) { + const d = Math.abs((Number(row?.t || 0) || 0) - targetT); + if (d < best) { best = d; nearest = row; } + } + if (!nearest) return hide(); + + const snappedX = state.pad.l + clamp(((Number(nearest.t || 0) || 0) - state.minT) / Math.max(1, state.maxT - state.minT), 0, 1) * state.w; + const scale = state.scale || { min: 0, max: 100, percent: false }; + const yForSeries = (seriesDef) => { + const raw = Number(nearest?.[seriesDef.key] || 0); + if (scale.percent) return state.pad.t + state.h - state.h * clamp(raw / 100, 0, 1); + const denom = Math.max(1, scale.max - scale.min); + return state.pad.t + state.h - state.h * clamp((raw - scale.min) / denom, 0, 1); + }; + let snapSeries = state.series[0] || null; + let snappedY = snapSeries ? yForSeries(snapSeries) : state.pad.t + state.h * 0.5; + let bestY = Math.abs(snappedY - py); + for (const seriesDef of state.series.slice(1)) { + const candidateY = yForSeries(seriesDef); + const distance = Math.abs(candidateY - py); + if (distance < bestY) { + bestY = distance; + snapSeries = seriesDef; + snappedY = candidateY; + } + } + + const marker = hoverMarker(); + marker.style.left = `${snappedX * rect.width / state.cssW}px`; + marker.style.top = `${snappedY * rect.height / state.cssH}px`; + if (snapSeries?.color) marker.style.borderColor = snapSeries.color; + marker.classList.remove("hidden"); + + const lines = state.series.map(series => `
${series.label}${formatChartValue(series.key, nearest)}
`).join(""); + tooltip.innerHTML = `${chartDateTimeLabelForTime(nearest.t)}${lines}`; + tooltip.classList.remove("hidden"); + tooltip.style.visibility = "hidden"; + tooltip.style.left = "0px"; + tooltip.style.top = "0px"; + + const tipW = Math.max(1, tooltip.offsetWidth || 160); + const tipH = Math.max(1, tooltip.offsetHeight || 80); + const viewportW = Math.max(1, window.innerWidth || document.documentElement.clientWidth || rect.right + tipW); + const viewportH = Math.max(1, window.innerHeight || document.documentElement.clientHeight || rect.bottom + tipH); + const gap = 10; + const anchorX = rect.left + snappedX * rect.width / state.cssW; + const anchorY = rect.top + snappedY * rect.height / state.cssH; + const placements = [ + { side: "right", left: rect.right + gap, top: anchorY - tipH / 2, fits: viewportW - rect.right >= tipW + gap }, + { side: "left", left: rect.left - tipW - gap, top: anchorY - tipH / 2, fits: rect.left >= tipW + gap }, + { side: "top", left: anchorX - tipW / 2, top: rect.top - tipH - gap, fits: rect.top >= tipH + gap }, + { side: "bottom", left: anchorX - tipW / 2, top: rect.bottom + gap, fits: viewportH - rect.bottom >= tipH + gap }, + ]; + const placement = placements.find(item => item.fits) || placements[0]; + const left = Math.max(gap, Math.min(viewportW - tipW - gap, placement.left)); + const top = Math.max(gap, Math.min(viewportH - tipH - gap, placement.top)); + tooltip.dataset.side = placement.side; + tooltip.style.left = `${Math.round(left)}px`; + tooltip.style.top = `${Math.round(top)}px`; + tooltip.style.visibility = "visible"; + }); +} + +window.bindColonyChartHover = bindColonyChartHover; + function niceChartMax(raw = 0) { const value = Math.max(1, Number(raw) || 0); const pow = Math.pow(10, Math.floor(Math.log10(value))); @@ -278,12 +424,14 @@ function chartAxisLabel(value, scale) { function drawColonyChart(values) { const chart = ui.colonyChart; if (!chart) return; + bindColonyChartHover(); let mode = uiCache.chartMode || "population"; if (!["population", "life", "objects"].includes(mode)) { mode = "population"; uiCache.chartMode = "population"; for (const b of ui.colonyChartTabs?.querySelectorAll("button[data-chart]") || []) b.classList.toggle("active", b.dataset.chart === "population"); } + const rangeConfig = chartRangeConfig(); const rows = chartRows(values); const last = rows[rows.length - 1] || values; const allSeries = chartSeries(mode); @@ -291,8 +439,10 @@ function drawColonyChart(values) { const series = allSeries.filter(s => !hiddenSeries.has(s.key)); const seriesSnapshot = allSeries.map(s => `${s.key}:${hiddenSeries.has(s.key) ? 0 : 1}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(","); const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); - const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength); - const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`; + const maxT = Math.max(0, Number(last?.t ?? world.time ?? 0) || 0); + const visibleDay = Math.floor(maxT / dayLength); + const lastRowT = Math.round(maxT * 10); + const snapshot = `${mode}:${rangeConfig.id}:${rows.length}:${lastRowT}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`; if (uiCache.lastChartDraw === snapshot) return; uiCache.lastChartDraw = snapshot; const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320)); @@ -301,20 +451,15 @@ function drawColonyChart(values) { const w = cssW - pad.l - pad.r; const h = cssH - pad.t - pad.b; const scale = chartScaleForRows(mode, rows, series); - const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0)); - const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0)); - const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1); - const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w; - const xFor = (i) => xForTime(rows[i]?.t ?? maxT); - const yFor = (row, s) => { - const raw = Number(row[s.key] || 0); - if (scale.percent) { - const v = clamp(raw / 100, 0, 1); - return pad.t + h - h * v; - } + const chartMaxT = Math.max(1, maxT); + const minT = Math.max(0, chartMaxT - rangeConfig.duration); + const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, chartMaxT - minT), 0, 1) * w; + const xFor = (i) => xForTime(rows[i]?.t ?? chartMaxT); + const yFor = (row, seriesDef) => { + const raw = Number(row[seriesDef.key] || 0); + if (scale.percent) return pad.t + h - h * clamp(raw / 100, 0, 1); const denom = Math.max(1, scale.max - scale.min); - const v = clamp((raw - scale.min) / denom, 0, 1); - return pad.t + h - h * v; + return pad.t + h - h * clamp((raw - scale.min) / denom, 0, 1); }; const grid = [0, 25, 50, 75, 100].map(yv => { const y = pad.t + h - h * yv / 100; @@ -322,23 +467,25 @@ function drawColonyChart(values) { return `${chartAxisLabel(rawLabel, scale)}`; }).join(""); const firstDay = Math.ceil(minT / dayLength); - const lastDay = Math.floor(maxT / dayLength); + const lastDay = Math.floor(chartMaxT / dayLength); const dayMarkers = []; for (let d = firstDay; d <= lastDay; d += 1) { const t = d * dayLength; - if (t < minT - 0.001 || t > maxT + 0.001) continue; + if (t < minT - 0.001 || t > chartMaxT + 0.001) continue; const x = xForTime(t); const label = chartSeasonDayLabelForTime(t); - dayMarkers.push(`${label}`); + const showLabel = rangeConfig.id !== "year" || ((d - firstDay) % 2 === 0); + dayMarkers.push(`${showLabel ? `${label}` : ""}`); } - const lines = series.map(s => { - const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" "); - const last = rows[rows.length - 1]; + const lines = series.map(seriesDef => { + const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, seriesDef).toFixed(1)}`).join(" "); + const latest = rows[rows.length - 1]; const cx = xFor(rows.length - 1).toFixed(1); - const cy = yFor(last, s).toFixed(1); - return ``; + const cy = yFor(latest, seriesDef).toFixed(1); + return ``; }).join(""); - chart.innerHTML = ``; + chart.innerHTML = ``; + uiCache.chartHoverState = { rows, series, minT, maxT: chartMaxT, pad, w, h, cssW, cssH, mode, range: rangeConfig.id, scale }; if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = allSeries.map(s => { const hidden = hiddenSeries.has(s.key); return ``; @@ -359,6 +506,7 @@ function chartSeries(mode = "population") { { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" }, { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, + { key: "obj_grave", label: "\u5893", color: "#77716a" }, { key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" }, { key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" }, { key: "temperature", label: "\u6c17\u6e29", color: "#c46f3c" }, diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js index d182e81..19dcbef 100644 --- a/js/ui_family_layout.js +++ b/js/ui_family_layout.js @@ -1,7 +1,5 @@ "use strict"; -function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } - function lineageNodeSort(a, b) { return (a.generation || 1) - (b.generation || 1) || (a.birthTime || 0) - (b.birthTime || 0) || @@ -12,6 +10,10 @@ function lineageNodeSort(a, b) { function lineageGeneticPercentFromNode(n, key = "") { const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; if (entity?.geneticStatRatio) return Math.round((entity.geneticStatRatio(key) - 1) * 100); + if (Array.isArray(n?.geneticSummary)) { + const index = { life: 0, size: 1, attack: 2, speed: 3 }[key]; + if (Number.isInteger(index) && Number.isFinite(Number(n.geneticSummary[index]))) return Math.round(Number(n.geneticSummary[index])); + } const g = typeof normalizeGenetics === "function" ? normalizeGenetics(n?.genetics, n?.id || "") : (n?.genetics || {}); const map = { life: "lifeSpanMul", attack: "attackMul", speed: "speedMul", size: "sizeMul" }; const ratio = Number(g?.[map[key] || key]) || 1; @@ -24,11 +26,15 @@ function lineageSignedPercentText(value = 0) { } +function familyDeathTimeText(value) { + return globalThis.TarinaiMemorialBellSystem?.formatWorldTime?.(value) || ""; +} + function lineageTreeNodeHtml(n, x, y) { const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; const live = entity && !entity.dead ? entity : null; const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; - const name = escapeHtml(n?.name || "\u4e0d\u660e"); + const name = window.TarinaiUIHelpers.htmlEscape(n?.name || "\u4e0d\u660e"); const gen = n?.generation ? `G${n.generation}` : "G?"; const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; const briefReason = familyDeathReasonLabel(rawReason, entity); @@ -38,15 +44,16 @@ function lineageTreeNodeHtml(n, x, y) { const tags = rawTags.slice(); const displayTags = tags.slice(0, 4); const tagText = tags.length ? ` / ${tags.join(" ")}` : ""; - const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`); - const deathLine = ""; - const tagLine = displayTags.length ? `${displayTags.map(tag => `${escapeHtml(tag)}`).join("")}` : ""; + const title = window.TarinaiUIHelpers.htmlEscape(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`); + const deathTimeText = !n?.alive ? familyDeathTimeText(n?.deathTime) : ""; + const deathLine = deathTimeText ? `${window.TarinaiUIHelpers.htmlEscape(deathTimeText)}` : ""; + const tagLine = displayTags.length ? `${displayTags.map(tag => `${window.TarinaiUIHelpers.htmlEscape(tag)}`).join("")}` : ""; const champion = !!(live?.isTarinaiChampion || n?.isTarinaiChampion); const statLine = n ? ` - \u5BFF${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))} - \u4F53${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))} - \u653B${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))} - \u901F${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))} + \u5BFF${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))} + \u4F53${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))} + \u653B${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))} + \u901F${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))} ` : ""; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const childCls = (n?.parents || []).length ? " child" : ""; @@ -54,12 +61,12 @@ function lineageTreeNodeHtml(n, x, y) { ? (live.effectiveScale ? live.effectiveScale() : (Number.isFinite(live.scale) ? live.scale : (Number.isFinite(live.adultScale) ? live.adultScale : 0.28))) : (Number.isFinite(n?.scale) ? n.scale : (Number.isFinite(n?.adultScale) ? n.adultScale : 0.28)); const iconScale = clamp((Number(iconBaseScale) || 0.28) / 0.28, 0.64, 1.42).toFixed(2); - const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; + const data = n?.id ? ` data-family-tarinai-id="${window.TarinaiUIHelpers.htmlEscape(n.id)}"` : ""; return `
${champion ? "\u265B " : ""}${name} - ${escapeHtml(gen)} / ${escapeHtml(status)}${champion ? " / \u738B\u8005" : ""} + ${window.TarinaiUIHelpers.htmlEscape(gen)} / ${window.TarinaiUIHelpers.htmlEscape(status)}${champion ? " / \u738B\u8005" : ""} ${statLine} ${deathLine} ${tagLine} @@ -378,7 +385,7 @@ function lineageBuildRowGroups(row, idSet) { function lineageLayoutRows(rows, idSet) { const NODE_W = 150; - const NODE_H = 104; + const NODE_H = 112; const SIBLING_GAP = 24; const GROUP_GAP = 42; const PARTNER_GROUP_GAP = 18; diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 25f18d5..a349c31 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -1,7 +1,5 @@ "use strict"; -function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } - function lineageSplitByActualEdges(component) { const family = lineageEdgeFamily(world.family || {}); const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); @@ -116,7 +114,7 @@ function lineageFamilyHtml(component, index, family, signature = "") { const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); const empty = !layout.childLinks.length ? `
\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
` : ""; - const html = `
+ const html = `

\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

-
${escapeHtml(card.title || "")}

${escapeHtml(card.text || "")}

+ +
${window.TarinaiUIHelpers.htmlEscape(card.title || "")}

${window.TarinaiUIHelpers.htmlEscape(card.text || "")}

`).join(""); ui.ecologyGrid.dataset.ready = "1"; } @@ -180,7 +178,13 @@ function showToast(text) { function syncTopButtons() { ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62"; - ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`; + if (ui.speedSelect) ui.speedSelect.value = String(world.speed); + if (ui.speedMenuBtn) ui.speedMenuBtn.textContent = `速度 ×${world.speed}`; + for (const option of ui.speedMenuPanel?.querySelectorAll?.("[data-speed-value]") || []) { + const active = Number(option.dataset.speedValue) === Number(world.speed); + option.classList.toggle("active", active); + option.setAttribute("aria-checked", active ? "true" : "false"); + } syncAudioControls(); syncArchiveUpdateButton(); } @@ -204,8 +208,9 @@ function setArchiveUpdateEnabled(on) { if (uiCache.archiveUpdateEnabled) { uiCache.archiveVersion = ""; uiCache.archiveFamilyVersion = null; + uiCache.archiveViewActivated = true; world.familyTreeDirty = true; - scheduleArchiveWindowRender?.(); + renderArchive?.({ userRequested: true }); showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002"); } else { if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; } diff --git a/js/ui_log.js b/js/ui_log.js index cf03f64..117c49b 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -1,7 +1,5 @@ "use strict"; -function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } - const LOG_EVENT_CATEGORIES = Object.freeze(["life", "danger", "social", "living", "environment", "observe"]); const LOG_EVENT_CATEGORY_LABELS = Object.freeze({ life: "\u751f\u547d", @@ -50,15 +48,6 @@ function loadLogPushSettings() { } return; } - // Keep the previous push master setting, but start the shared event filters - // with all categories visible so upgrading never hides historical logs. - const legacyRaw = localStorage.getItem("tarinai_log_push_settings_v2"); - if (legacyRaw) { - const legacy = JSON.parse(legacyRaw); - uiCache.logPushEnabled = Boolean(legacy.enabled); - uiCache.logEventCategories = allLogEventCategories(); - saveLogPushSettings(); - } } catch (_) {} } @@ -151,7 +140,7 @@ function renderLog(logs) { ui.log.innerHTML = ""; const frag = document.createDocumentFragment(); const touchFirst = Boolean(window.TarinaiInputMode.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980); - const limit = touchFirst ? 34 : 80; + const limit = touchFirst ? 34 : 40; const visibleLogs = Array.isArray(logs) ? logs.map(raw => typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw).filter(shouldShowInEventLog) : []; @@ -229,9 +218,9 @@ function relationListHtml(t, kind = "friend") { if (!rows.length) return "\u306a\u3057"; const sign = kind === "friend" ? "+" : ""; return `
${rows.map(r => ` - `).join("")}
`; } diff --git a/js/ui_selected.js b/js/ui_selected.js index 456166e..0f39cd0 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -1,7 +1,5 @@ "use strict"; -function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } - function quoteNameInRecordText(text, name) { const raw = String(text || ""); const n = String(name || "").trim(); @@ -48,7 +46,7 @@ function personalityValueText(value) { function personalityRowsHtml(personality = {}) { const p = normalizePersonality(personality); - return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); + return PERSONALITY_KEYS.map(key => `${window.TarinaiUIHelpers.htmlEscape(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); } function personalityShortLabel(key) { @@ -129,9 +127,9 @@ function personalityDailyChangeHtml(t) { if (up > 0.0001) parts.push(`${personalityShortLabel(key)} +${up.toFixed(3)}`); if (down > 0.0001) parts.push(`${personalityShortLabel(key)} -${down.toFixed(3)}`); } - if (parts.length) rows.push(`
  • ${escapeHtml(cause)}${escapeHtml(parts.join(" / "))}${escapeHtml(dayLabel)}
  • `); + if (parts.length) rows.push(`
  • ${window.TarinaiUIHelpers.htmlEscape(cause)}${window.TarinaiUIHelpers.htmlEscape(parts.join(" / "))}${window.TarinaiUIHelpers.htmlEscape(dayLabel)}
  • `); } - return rows.join("") || `
  • \u8981\u56e0\u5909\u5316\u306a\u3057${escapeHtml(dayLabel)}
  • `; + return rows.join("") || `
  • \u8981\u56e0\u5909\u5316\u306a\u3057${window.TarinaiUIHelpers.htmlEscape(dayLabel)}
  • `; } function personalityDailySnapshot(t) { @@ -153,15 +151,15 @@ function personalityDailySnapshot(t) { function selectedDataCategoryHtml(id, label, bodyHtml) { if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); const collapsed = uiCache.selectedCollapsedCategories.has(id); - return `
    - + return `
    +
    ${bodyHtml}
    `; } function selectedInfoRowHtml(label, valueHtml, metric = "") { - const metricAttr = metric ? ` data-metric="${escapeHtml(metric)}"` : ""; - return `
    ${escapeHtml(label)}${valueHtml}
    `; + const metricAttr = metric ? ` data-metric="${window.TarinaiUIHelpers.htmlEscape(metric)}"` : ""; + return `
    ${window.TarinaiUIHelpers.htmlEscape(label)}${valueHtml}
    `; } @@ -180,7 +178,7 @@ function selectedInventoryItems(t) { function selectedInventoryHtml(t) { const entries = selectedInventoryItems(t); if (!entries.length) return `
    \u6240\u6301\u54c1\u306a\u3057
    `; - return `
    \u6240\u6301\u54c1${entries.map(v => `${escapeHtml(v)}`).join("")}
    `; + return `
    \u6240\u6301\u54c1${entries.map(v => `${window.TarinaiUIHelpers.htmlEscape(v)}`).join("")}
    `; } function renderSelected() { @@ -246,7 +244,7 @@ function renderSelected() { if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); const personalityTagHtml = personalityTags.length - ? personalityTags.map(tag => `${escapeHtml(tag)}`).join("") + ? personalityTags.map(tag => `${window.TarinaiUIHelpers.htmlEscape(tag)}`).join("") : `\u4e2d\u7acb`; const recentChangeHtml = personalityDailyChangeHtml(t); const personalityHtml = ` @@ -258,10 +256,10 @@ function renderSelected() { const recentMemoryHtml = (t.records || []).slice(0, 7).map(r => { const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); const recordText = quoteNameInRecordText(r.text || "", t.name); - return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; + return `
  • ${window.TarinaiUIHelpers.htmlEscape(timeText)}${window.TarinaiUIHelpers.htmlEscape(recordText)}
  • `; }).join("") || `
  • -\u307e\u3060\u8a18\u61b6\u304c\u3042\u308a\u307e\u305b\u3093
  • `; const basicHtml = ` -
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    +
    \u4e16\u4ee3${window.TarinaiUIHelpers.htmlEscape(generationDisplay || "\u4e0d\u660e")}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    \u6700\u5927\u5bff\u547d${geneticPercentText(t, "life")}
    \u653b\u6483\u529b${geneticPercentText(t, "attack")}
    @@ -273,10 +271,10 @@ function renderSelected() { const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key; const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : ""; - return `
    ${escapeHtml(label)}${escapeHtml(value)}
    `; + return `
    ${window.TarinaiUIHelpers.htmlEscape(label)}${window.TarinaiUIHelpers.htmlEscape(value)}
    `; }).join(""); const healthHtml = [ - `
    \u884c\u52d5${escapeHtml(reasonLabel(t))}
    `, + `
    \u884c\u52d5${window.TarinaiUIHelpers.htmlEscape(reasonLabel(t))}
    `, `
    ${needRowsHtml}
    `, selectedInfoRowHtml("\u4f53\u529b", `${fmt(t.energy)} / ${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, "energy"), selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), @@ -284,19 +282,19 @@ function renderSelected() { selectedInfoRowHtml("\u6e80\u8179\u5ea6", fmt(Math.max(0, Math.min(100, 100 - (Number(t.hunger) || 0)))), "fullness"), ].join(""); const itemEffectHtml = itemEffectSummary.length - ? itemEffectSummary.map(e => `
    ${escapeHtml(e.label)}${escapeHtml(e.detail)}
    `).join("") + ? itemEffectSummary.map(e => `
    ${window.TarinaiUIHelpers.htmlEscape(e.label)}${window.TarinaiUIHelpers.htmlEscape(e.detail)}
    `).join("") : `
    \u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u306a\u3057
    `; const relationHtml = `
    \u53cb\u9054${relationListHtml(t, "friend")}
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    -
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    +
    \u89aa${window.TarinaiUIHelpers.htmlEscape(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    \u5b50${t.children?.length || 0}
    -
    \u3051\u3093\u304b\u52dd\u7387${escapeHtml(fightWinRateLabel(t))}
    +
    \u3051\u3093\u304b\u52dd\u7387${window.TarinaiUIHelpers.htmlEscape(fightWinRateLabel(t))}
    \u79f0\u53f7${t.isTarinaiChampion ? "\u305f\u308a\u306a\u3044\u738b\u8005" : "\u306a\u3057"}
    `; ui.selectedInfo.innerHTML = `
    - +
    ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""}
    diff --git a/js/ui_tools.js b/js/ui_tools.js index 110f59a..217b43a 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -2,7 +2,7 @@ const SCALABLE_TOOLS = new Set(scalableToolIds()); const PHYSICS_ICON_TOOL_IDS = new Set(window.TarinaiItemToolMetadata?.PHYSICS_TOOL_IDS || []); -const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "copy", "undo", "redo", "area_delete", "clear_tool"]); +const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "copy", "undo", "redo", "area_delete", "clear_tool", "bell"]); function drawPhysicsToolIcon(canvas, toolId) { @@ -140,7 +140,6 @@ const PICK_MODE_NAMES = Object.freeze({ function normalizedToolPickMode() { const value = String(world?.toolPickMode || "free").toLowerCase(); - if (value === "grid") return "grid40"; return PICK_MODE_LABELS[value] ? value : "free"; } @@ -342,8 +341,8 @@ function renderToolPalette() { if (!hasPhysicsIcon) attachToolSpritePreview(btn, def, toolId); if (toolId === world?.tool) btn.classList.add("selected"); body.appendChild(btn); - if (category.id === "operate" && toolId === "clear_tool") body.appendChild(createToolPickModeControl()); } + if (category.id === "operate") body.appendChild(createToolPickModeControl()); section.append(toggle, body); frag.appendChild(section); } diff --git a/js/version.js b/js/version.js index cfb0386..a579a53 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.16.109"; - const APP_BUILD = "tarinai-ui-v39-16-109"; + const APP_VERSION = "39.16.137"; + const APP_BUILD = "tarinai-ui-v39-16-137"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index df8939d..88c6c81 100644 --- a/js/world.js +++ b/js/world.js @@ -5,7 +5,9 @@ class World { constructor() { this.h = 720; this.viewportW = 1000; this.viewportH = 720; + this._worldSizeInitialized = false; this.time = 0; + this._fixedSimulationAccumulator = 0; this.day = 1; this.elapsedDays = 0; this.paused = false; @@ -76,9 +78,16 @@ class World { constructor() { resetRuntimeCollections() { this.tarinai = []; this.items = []; + this.residues = []; + this.residueById = new Map(); + this.residueTypeBuckets = new Map(); + this.residueCells = new Map(); + this.residueSerial = 0; + this.residueDecayAccumulator = 0; this.ants = []; this.effects = []; this.logs = []; + this.memorialRequests = []; this.events = window.TarinaiEvents || null; this.eventCounters = {}; this.countsTimer = 0; @@ -96,6 +105,7 @@ class World { constructor() { this.achievementNaturalSlaveGenerations = []; this.achievementNaturalKingGenerations = []; this.achievementPlayerPlacementCount = 0; + this.achievementPlayerPlacedActiveCount = 0; this.achievementManualTarinaiAddedCount = 0; this.achievementDirectFeedCount = 0; this.achievementRobotCleanCount = 0; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 9918e4b..2ddfebd 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -4,7 +4,7 @@ (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_combat_effects.js"); - const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"]); + const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind", "ascend_soul"]); const EFFECT_POOL_LIMIT_PER_TYPE = 96; const effectPool = new Map(); @@ -28,13 +28,15 @@ Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ queueEffect(type, x, y, options = {}) { - // The visual-effects toggle is a true simulation gate: when disabled, - // do not allocate, enqueue, update, or draw transient Effect objects. - if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null; const opts = options || {}; + const forceVisual = opts.forceVisual === true || type === "ascend_soul"; + // Semantic one-shot effects such as grave ascension remain visible even when + // optional ambient effects are disabled. + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null; const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1)))); const effect = acquireEffect(type, x, y, opts); effect._effectImportance = importance; + effect._forceVisual = forceVisual; this.effects.push(effect); if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) { this._effectSpawnStats = this._effectSpawnStats || {}; @@ -45,8 +47,9 @@ }, spawnEffect(type, x, y, options = {}) { - if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null; const opts = options || {}; + const forceVisual = opts.forceVisual === true || type === "ascend_soul"; + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null; const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1)))); if (importance <= 1 && opts.allowOffscreen !== true) { const margin = Math.max(80, Number(opts.offscreenMargin || 140)); @@ -112,6 +115,53 @@ }, + applyExplosionExternalForces(x, y, blastRadius, source = null, scale = 1, salt = "explosion-force") { + globalThis.TarinaiItemEnvironmentHazardSystem?.igniteOilNear?.(this, x, y, blastRadius * 1.05); + for (const can of this.itemsOfType?.("poison_gas_can") || []) { + if (!can || can.dead || distXY(x, y, can.x, can.y) > blastRadius) continue; + global.TarinaiStructureLifecycle?.deleteItem?.(this, can, { reason: "explosion", breaker: source }); + } + const radius = Math.max(1, Number(blastRadius || 0) || 1); + // Mechanical bodies can have long custom shapes, so do not rely on a spatial + // query that only sees their center/radius. Scan the bounded item list and use + // the mechanical extent for blast reach; this keeps rotators and reciprocators + // consistent even immediately after placement or shape edits. + const affected = this.items || []; + let moved = 0; + for (const item of affected) { + if (!item || item.dead || item === source) continue; + const mechanical = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type); + if (!mechanical && item.type !== "grave") continue; + let dx = (Number(item.x || 0) || 0) - x; + let dy = (Number(item.y || 0) || 0) - y; + let d = Math.hypot(dx, dy); + const bodyExtent = mechanical ? Math.max(18, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 0) || 0) : Math.max(16, Number(item.r || 0) || 0); + if (d > radius + bodyExtent) continue; + if (d < 0.001) { + const a = deterministicAngle(this, `${salt}-overlap`, source || item, item); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const q = clamp(1 - d / Math.max(1, radius), 0, 1); + const force = (430 + q * 960) * Math.max(0.1, Number(scale || 1) || 1); + const nx = dx / d, ny = dy / d; + if (item.type === "grave") { + if (global.TarinaiMemorialBellSystem?.applyExternalForce?.(this, item, force, source, "explosion")) moved += 1; + continue; + } + const contactScale = Math.max(18, Math.min(160, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 40) || 40)) * 0.55; + // Give rotators a small off-centre pressure component as well as translation. + // A purely radial force through the pivot produces zero torque and previously + // made blasts look as though they did nothing to a rotator. + const tangentOffset = item.type === "rotator" ? Math.min(56, contactScale * 0.42) : 0; + const contactX = item.x - nx * contactScale - ny * tangentOffset; + const contactY = item.y - ny * contactScale + nx * tangentOffset; + const hit = global.TarinaiMechanicalSystem?.applyExternalImpulse?.(item, contactX, contactY, nx * force, ny * force, this, item.type === "rotator" ? 1.18 : 1); + if (hit) moved += 1; + } + if (moved) { this.markSpatialDirty?.("explosion-external-force"); this.drawListDirty = true; } + return moved; + }, + blastLoosePinsFrom(x, y, blastRadius, source = null, scale = 1, salt = "blast") { let blastedPins = 0; const pinSystem = globalThis.TarinaiItemDynamicPinSystem; @@ -221,6 +271,7 @@ if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it); + this.applyExplosionExternalForces?.(x, y, blastRadius, it, it.blastScale || 1, "firecracker"); this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), source: it, @@ -269,6 +320,7 @@ this.spawnEffect("ring", x, y, { size: (30 + r * 24) * blastScale, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }); } this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale); + this.applyExplosionExternalForces?.(x, y, blastRadius, t, blastScale, "disease-explosion"); for (let i = 0; i < 34; i++) { const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i); const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale; @@ -360,15 +412,18 @@ const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1); const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7); let cleaned = 0; + let itemTouched = false; for (const it of this.nearbyItems(x, y, radius + 80)) { if (!it || it.dead) continue; const d = distXY(it.x, it.y, x, y); if (d > radius + (it.r || 12)) continue; const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1); - if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") { - it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p); + if (it.type === "zunchi") { + itemTouched = true; + it.amount -= dt * 230 * (0.35 + p); cleaned += p; } else if (it.type === "grass") { + itemTouched = true; it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p)); it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p)); if (it.amount <= 0.5) { @@ -379,6 +434,7 @@ } cleaned += p * 0.72; } else if (it.type === "fire") { + itemTouched = true; it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 540 * (0.35 + p)); it.flameLife = Math.max(0, (Number(it.flameLife) || 0) - dt * 5.2 * (0.35 + p)); it.fireShrinkPulse = Math.max(Number(it.fireShrinkPulse || 0), 0.18); @@ -388,12 +444,31 @@ } cleaned += p * 1.15; } else if (it.type === "grass_bed") { + itemTouched = true; it.amount = 0; it.hp = 0; - global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "\u6d17\u6d44\u3067\u6d41\u3055\u308c\u305f", wake: true }); + global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "洗浄で流された", wake: true }); cleaned += p * 0.95; + } else if (it.type === "grave") { + itemTouched = true; + const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, it, { + reason: "water-hose-grave", + userReason: "洗浄で流された", + wake: true, + }); + if (removed) cleaned += p * 1.05; } } + for (const residue of this.nearbyResidues?.(x, y, radius + 80) || []) { + if (!residue || residue.dead) continue; + const d = distXY(residue.x, residue.y, x, y); + if (d > radius + (residue.r || 12)) continue; + const p = clamp(1 - d / (radius + (residue.r || 12)), 0, 1); + residue.amount = Math.max(0, (Number(residue.amount) || 0) - dt * 180 * (0.35 + p)); + cleaned += p; + if (residue.amount <= 0.5) this.removeResidue?.(residue, "water-hose-residue"); + else this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "water-hose-residue"); + } for (const ef of this.effects || []) { if (!ef || ef.dead) continue; if (ef.type !== "bleed" && ef.type !== "splat") continue; @@ -432,11 +507,13 @@ } if (cleaned > 0.25) { this.drawListDirty = true; - this.markItemBucketsDirty?.("water-hose-clean"); this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean"); - this.markSpatialDirty?.("water-hose-clean"); - this.compactItems?.(); - this.updateItemCounts(); + if (itemTouched) { + this.markItemBucketsDirty?.("water-hose-clean"); + this.markSpatialDirty?.("water-hose-clean"); + this.compactItems?.(); + this.updateItemCounts(); + } } }, @@ -512,18 +589,29 @@ }, spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { - if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; + const bubbleLimit = Math.max(1, Number(CONFIG.bubbleLimit || 32) || 32); + let bubbleCount = Math.max(0, Number(this.effectCounts.bubble || 0) || 0); + if (bubbleCount >= bubbleLimit) { + bubbleCount = 0; + for (const effect of this.effects || []) if (effect && !effect.dead && effect.type === "bubble") bubbleCount += 1; + this.effectCounts.bubble = bubbleCount; + if (bubbleCount >= bubbleLimit) return; + } if (text === "z") text = "Zzz..."; audio.bubble(text); - this.spawnEffect("bubble", x, y, { + const effect = this.spawnEffect("bubble", x, y, { vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text), vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text), life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text), size: 8, text, color, + forceVisual: true, + importance: 3, }); + if (!effect) return null; this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1; + return effect; }, spawnHeadbuttEffect(x, y) { diff --git a/js/world_family_social.js b/js/world_family_social.js index dc4e921..4b3e6ba 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -2,37 +2,47 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { - const axes = ["aggression", "openness", "sociability"]; + const inheritedUnit = (...parts) => typeof globalThis.stableUnit === "function" ? globalThis.stableUnit(...parts) : 0.5; + const axes = ["aggression", "openness", "sociability", "neuroticism"]; const profileFor = (birthSeed, profileOpts = {}) => globalThis.TarinaiSeedFactory?.birthProfile?.(birthSeed || "", profileOpts)?.birthPersonality || {}; - const childBase = normalizePersonality(profileFor(seed, opts)); - let inheritedAny = false; + const randomBase = normalizePersonality(profileFor(seed, opts)); + const validParents = (parents || []).filter(Boolean).slice(0, 2); + if (!validParents.length) return null; + const child = { ...randomBase }; for (const key of axes) { + const values = validParents.map(parent => { + const parentBase = normalizePersonality(profileFor(parent.birthSeed || parent.familyKey || "")); + return Number(normalizePersonality(parent.birthPersonality || parentBase)[key]) || 0; + }); + let inherited = values[0] || 0; + if (values.length > 1) { + const aWeight = 0.42 + inheritedUnit(seed, `personality-parent-weight-${key}`) * 0.16; + inherited = values[0] * aWeight + values[1] * (1 - aWeight); + } + const mutation = (inheritedUnit(seed, `personality-mut-${key}`) - 0.5) * 0.04; + child[key] = clampPersonalityValue(inherited * 0.94 + (Number(randomBase[key]) || 0) * 0.06 + mutation); + } + + // Champion lineage boosts remain heritable on top of the stronger ordinary + // personality inheritance, so royal descendants still preserve that trait. + for (const key of ["aggression", "openness", "sociability"]) { let totalLegacy = 0; let parentCount = 0; - for (const parent of parents || []) { - if (!parent) continue; + for (const parent of validParents) { + if (!parent.isTarinaiChampion) continue; const parentBase = normalizePersonality(profileFor(parent.birthSeed || parent.familyKey || "")); - const born = normalizePersonality(parent.birthPersonality || parentBase); - // A champion's three boosted axes become a heritable lineage delta. A - // descendant that inherited that delta can pass a damped form onward. - const inheritedDelta = Math.max(0, (Number(born[key]) || 0) - (Number(parentBase[key]) || 0)); - let championDelta = 0; - if (parent.isTarinaiChampion) { - const baseValue = Number(parentBase[key]) || 0; - const championTarget = clampPersonalityValue(Math.max(baseValue + 0.48, 0.86)); - championDelta = Math.max(0, championTarget - baseValue); - } - const legacy = Math.max(inheritedDelta, championDelta); - if (legacy <= 0) continue; - totalLegacy += legacy; + const baseValue = Number(parentBase[key]) || 0; + const championTarget = clampPersonalityValue(Math.max(baseValue + 0.48, 0.86)); + const championDelta = Math.max(0, championTarget - baseValue); + if (championDelta <= 0) continue; + totalLegacy += championDelta; parentCount += 1; } if (parentCount > 0 && totalLegacy > 0) { - childBase[key] = clampPersonalityValue((Number(childBase[key]) || 0) + (totalLegacy / parentCount) * 0.70); - inheritedAny = true; + child[key] = clampPersonalityValue((Number(child[key]) || 0) + (totalLegacy / parentCount) * 0.82); } } - return inheritedAny ? childBase : null; + return child; } (function (global) { @@ -154,7 +164,8 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { for (const item of nearby) { if (!item || item.dead || item.type === "trace" || item.type === "splat") continue; const isSleepFurniture = sleepFurniture?.has?.(item.type) || ["bed", "nest_box", "pipe", "grass_bed"].includes(item.type); - if (!isSleepFurniture) continue; + const isMemorial = item.type === "grave"; + if (!isSleepFurniture && !isMemorial) continue; const isNest = global.TarinaiToolRuntime?.isNestContainerItem?.(item); const isOwnedBed = item.type === "grass_bed"; const fallbackDistance = distXY(p.x, p.y, Number(item.x || 0), Number(item.y || 0)); @@ -182,6 +193,9 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { if (best.type === "grass_bed") { return { target: best, lines: ownedBedTooltipLinesLocal(worldRef, best), kind: "grass_bed" }; } + if (best.type === "grave") { + return { target: best, lines: global.TarinaiMemorialBellSystem?.graveTooltipLines?.(best) || ["墓"], kind: "grave" }; + } const definition = global.TarinaiToolRuntime?.itemDefinition?.(best.type) || null; const lines = [definition?.label || String(best.type || "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8")]; if (definition?.tooltip) lines.push(definition.tooltip); @@ -196,8 +210,9 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { const preset = normalizeResetPresetId(presetId); this.fieldType = field.id; this.fieldZoom = 1; - this.setViewportSize(this.viewportW, this.viewportH); + this.setViewportSize(this.viewportW, this.viewportH, { resizeWorld: true }); this.time = 0; + this._fixedSimulationAccumulator = 0; this.day = 1; this.elapsedDays = 0; this.paused = false; @@ -289,6 +304,16 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { return t && !t.dead ? t : null; }, + compactFamilyGeneticSummary(genetics = null) { + const g = typeof normalizeGenetics === "function" ? normalizeGenetics(genetics, "") : (genetics || {}); + return [ + Math.round(((Number(g.lifeSpanMul) || 1) - 1) * 100), + Math.round(((Number(g.sizeMul) || 1) - 1) * 100), + Math.round(((Number(g.attackMul) || 1) - 1) * 100), + Math.round(((Number(g.speedMul) || 1) - 1) * 100), + ]; + }, + recordFamily(t) { if (!this.family) this.family = {}; const familyKey = this.tarinaiFamilyKey(t); @@ -332,6 +357,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { isTarinaiChampion: nextChampion, alive: !t.dead, deathReason: t.deathReason || "", + deathTime: t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : null, }; changed = true; } else { @@ -356,6 +382,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { if ((Number(entry.totalFightLosses) || 0) !== nextFightLosses) changed = true; if (!!entry.isTarinaiChampion !== nextChampion) changed = true; if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; + if ((entry.deathTime ?? null) !== (t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (entry.deathTime ?? null))) changed = true; if (entry.alive !== nextAlive) changed = true; if (!sameList(entry.parents, nextParents)) changed = true; if (!sameList(entry.children, nextChildren)) changed = true; @@ -375,10 +402,22 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { entry.isTarinaiChampion = nextChampion; entry.hasPaired = !!t.hasPaired; entry.deathReason = t.deathReason || entry.deathReason || ""; + entry.deathTime = t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (entry.deathTime ?? null); entry.parents = nextParents; entry.parentNames = [...(t.parentNames || entry.parentNames || [])]; entry.children = nextChildren; } + if (t.dead && this.family[familyKey]) { + const archived = this.family[familyKey]; + archived.geneticSummary = this.compactFamilyGeneticSummary(archived.genetics); + delete archived.genetics; + delete archived.personality; + delete archived.lifeSpan; + delete archived.growth; + delete archived.adultScale; + delete archived.totalFightWins; + delete archived.totalFightLosses; + } if (changed) { this.familyVersion = (this.familyVersion || 0) + 1; this.markFamilyTreeDirty("family-record"); @@ -393,11 +432,12 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { if (!this.family) this.family = {}; if (this.familyCleanVersion === (this.familyVersion || 0)) return false; const family = this.family; - const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); + const liveById = new Map((this.tarinai || []).filter(t => t && !t.dead).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); let changed = false; const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); for (const t of this.tarinai || []) { + if (!t || t.dead) continue; const familyKey = this.tarinaiFamilyKey(t); if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue; const before = this.familyVersion || 0; @@ -428,15 +468,21 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { children, birthTime: live?.birthTime ?? n.birthTime ?? 0, scale: Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : (Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : 0.28))), - adultScale: Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : (Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : 0.28))), - growth: Number.isFinite(live?.growth) ? live.growth : (Number.isFinite(n.growth) ? n.growth : 1), - genetics: live && typeof normalizeGenetics === "function" ? normalizeGenetics(live.genetics, live.familyKey || live.id || "") : (typeof normalizeGenetics === "function" ? normalizeGenetics(n.genetics, id) : { ...(n.genetics || {}) }), - lifeSpan: Number(live?.lifeSpan || n.lifeSpan || 0) || 0, - totalFightWins: Number(live?.totalFightWins ?? n.totalFightWins ?? 0) || 0, - totalFightLosses: Number(live?.totalFightLosses ?? n.totalFightLosses ?? 0) || 0, - isTarinaiChampion: !!(live?.isTarinaiChampion || n.isTarinaiChampion), + ...(live ? { + adultScale: Number.isFinite(live.adultScale) ? live.adultScale : (Number.isFinite(live.scale) ? live.scale : 0.28), + growth: Number.isFinite(live.growth) ? live.growth : 1, + genetics: typeof normalizeGenetics === "function" ? normalizeGenetics(live.genetics, live.familyKey || live.id || "") : { ...(live.genetics || {}) }, + lifeSpan: Number(live.lifeSpan || 0) || 0, + totalFightWins: Number(live.totalFightWins || 0) || 0, + totalFightLosses: Number(live.totalFightLosses || 0) || 0, + isTarinaiChampion: !!live.isTarinaiChampion, + } : { + geneticSummary: Array.isArray(n.geneticSummary) ? n.geneticSummary.slice(0, 4) : this.compactFamilyGeneticSummary(n.genetics), + isTarinaiChampion: !!n.isTarinaiChampion, + }), alive: live ? !live.dead : n.alive !== false, deathReason: live?.deathReason || n.deathReason || "", + deathTime: live?.deathTime != null && Number.isFinite(Number(live.deathTime)) ? Number(live.deathTime) : (n.deathTime != null && Number.isFinite(Number(n.deathTime)) ? Number(n.deathTime) : null), }; for (const key of Object.keys(next)) { const a = family[id]?.[key]; @@ -549,15 +595,25 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { }, markDead(t, reason) { - this.clearLiveReferencesTo(t); if (!this.family) this.family = {}; const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; + if (t && (t.deathTime == null || !Number.isFinite(Number(t.deathTime)))) t.deathTime = Number(this.time || 0) || 0; + global.TarinaiMemorialBellSystem?.captureDeath?.(this, t, finalReason); + this.clearLiveReferencesTo(t); const familyKey = this.tarinaiFamilyKey(t); if (familyKey && this.family[familyKey]) { - if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) { + if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason || this.family[familyKey].deathTime == null) { this.family[familyKey].alive = false; this.family[familyKey].deathReason = finalReason; + this.family[familyKey].deathTime = t?.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (Number(this.time || 0) || 0); + this.family[familyKey].geneticSummary = this.compactFamilyGeneticSummary(this.family[familyKey].genetics); + delete this.family[familyKey].genetics; delete this.family[familyKey].personality; + delete this.family[familyKey].lifeSpan; + delete this.family[familyKey].growth; + delete this.family[familyKey].adultScale; + delete this.family[familyKey].totalFightWins; + delete this.family[familyKey].totalFightLosses; this.familyVersion = (this.familyVersion || 0) + 1; this.markFamilyTreeDirty("death"); } diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 2a59cd5..8305782 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -170,6 +170,7 @@ +
    @@ -741,7 +742,7 @@ } if (item.type === "robot_cleaner") { const mask = Number(data.robotCleanerMask); - item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); + item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x7f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); item.robotCleanerHighSpeed = Boolean(data.robotCleanerHighSpeed); item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60); item.robotCleanerTargetId = ""; @@ -891,7 +892,7 @@ const timeEnd = dialog.querySelector("#pressureSwitchTimeEnd"); const timeStartLabel = dialog.querySelector("#pressureSwitchTimeStartLabel"); const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel"); - const normalizeMinute = (value, fallback) => Math.max(0, Math.min(1435, Math.round((Number.isFinite(Number(value)) ? Number(value) : fallback) / 5) * 5)); + const normalizeMinute = global.TarinaiSignalSystem.normalizeMinute; const formatMinute = value => { const minute = normalizeMinute(value, 0); return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; }; const targetDef = () => DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai; const normalizeValue = (value, fallback, def = targetDef()) => { @@ -1513,7 +1514,7 @@ handleClick(x, y) { const tool = this.tool; - if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) { + if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new", "bell"].includes(tool)) { const editable = findEditableItemAt(this, x, y); if (editable && openEditableItemEditor(editable, this)) { global.TarinaiCommands.setSelection(this, null) || (this.selected = null); @@ -1588,6 +1589,13 @@ return; } + if (tool === "bell") { + const size = this.toolSizeFor?.("bell") || "medium"; + global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "bell", x, y }); + global.TarinaiMemorialBellSystem?.ringAt?.(this, x, y, size); + return; + } + if (tool === "new") { const spot = this.edgeSpawnPoint(x, y, 42, true); const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 }); diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index bef484d..cc59614 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -201,12 +201,10 @@ this.ensureSpatial?.("nearby-items"); const out = this.spatialItemScratch || []; out.length = 0; - if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) { - this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise); - this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise); - return out; - } - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise); + if (!this.spatial?.nearbyInto) return out; + this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise); + this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise); + return out; }, nearbyGrass(x, y, radius, precise = false) { @@ -253,26 +251,20 @@ }, nearbyFood(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-food"); - if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) { - return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise); - } - return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise); + if (!this.spatial?.nearbySplitInto) return this.spatialFoodScratch; + return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise); }, nearbyObstacles(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-obstacles"); - if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) { - return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise); - } - return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise); + if (!this.spatial?.nearbySplitInto) return this.spatialObstacleScratch; + return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise); }, nearbyHazards(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-hazards"); - if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) { - return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise); - } - return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise); + if (!this.spatial?.nearbySplitInto) return this.spatialHazardScratch; + return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise); }, rebuildSpatial(force = false, reason = "manual") { // Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial @@ -449,9 +441,135 @@ return item; }, + residueCellKey(x, y) { + const size = 160; + return `${Math.floor((Number(x) || 0) / size)}:${Math.floor((Number(y) || 0) / size)}`; + }, + + addResidue(type, x, y, opts = {}) { + const residueType = String(type || ""); + if (residueType !== "trace" && residueType !== "splat") return null; + if (!(this.residueById instanceof Map)) this.residueById = new Map(); + if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map(); + if (!(this.residueCells instanceof Map)) this.residueCells = new Map(); + if (!Array.isArray(this.residues)) this.residues = []; + const id = String(opts.id || `res${++this.residueSerial}`); + const residue = { + id, type: residueType, + x: Number(x) || 0, y: Number(y) || 0, + r: Math.max(1, Number(opts.r ?? (residueType === "splat" ? 26 : 16)) || 1), + amount: Math.max(0, Number(opts.amount ?? (residueType === "splat" ? 260 : 220)) || 0), + seed: Number.isFinite(Number(opts.seed)) ? Number(opts.seed) : Math.random() * 1000, + dead: false, + _memorialReservedBy: String(opts._memorialReservedBy || ""), + }; + this.residues.push(residue); + this.residueById.set(id, residue); + let typeBucket = this.residueTypeBuckets.get(residueType); + if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residueType, typeBucket); } + typeBucket.add(residue); + const cellKey = this.residueCellKey(residue.x, residue.y); + residue._cellKey = cellKey; + let cell = this.residueCells.get(cellKey); + if (!cell) { cell = new Set(); this.residueCells.set(cellKey, cell); } + cell.add(residue); + this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r, 36), opts.reason || "residue-add"); + return residue; + }, + + rebuildResidueIndex() { + if (!(this.residueById instanceof Map)) this.residueById = new Map(); + if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map(); + if (!(this.residueCells instanceof Map)) this.residueCells = new Map(); + this.residueById.clear(); this.residueTypeBuckets.clear(); this.residueCells.clear(); + for (const residue of this.residues || []) { + if (!residue || residue.dead || residue.amount <= 0) continue; + this.residueById.set(String(residue.id), residue); + let typeBucket = this.residueTypeBuckets.get(residue.type); + if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residue.type, typeBucket); } + typeBucket.add(residue); + const key = this.residueCellKey(residue.x, residue.y); residue._cellKey = key; + let cell = this.residueCells.get(key); + if (!cell) { cell = new Set(); this.residueCells.set(key, cell); } + cell.add(residue); + } + return this.residueById.size; + }, + + residueByRuntimeId(id) { + if (id == null || id === "") return null; + const residue = this.residueById?.get?.(String(id)) || null; + return residue && !residue.dead && residue.amount > 0 ? residue : null; + }, + + residuesOfType(type) { + return this.residueTypeBuckets?.get?.(String(type || "")) || []; + }, + + nearbyResidues(x, y, radius, type = "") { + const out = this._nearbyResidueScratch || (this._nearbyResidueScratch = []); + out.length = 0; + if (!(this.residueCells instanceof Map) || !this.residueCells.size) return out; + const size = 160; + const r = Math.max(0, Number(radius) || 0); + const minX = Math.floor(((Number(x) || 0) - r) / size), maxX = Math.floor(((Number(x) || 0) + r) / size); + const minY = Math.floor(((Number(y) || 0) - r) / size), maxY = Math.floor(((Number(y) || 0) + r) / size); + const wanted = String(type || ""); + const r2 = r * r; + for (let cy = minY; cy <= maxY; cy++) for (let cx = minX; cx <= maxX; cx++) { + const cell = this.residueCells.get(`${cx}:${cy}`); + if (!cell) continue; + for (const residue of cell) { + if (!residue || residue.dead || residue.amount <= 0 || (wanted && residue.type !== wanted)) continue; + const dx = residue.x - x, dy = residue.y - y; + if (dx * dx + dy * dy <= r2) out.push(residue); + } + } + return out; + }, + + removeResidue(residue, reason = "residue-remove") { + if (!residue || residue.dead) return false; + residue.dead = true; + residue.amount = 0; + this.residueById?.delete?.(residue.id); + const typeBucket = this.residueTypeBuckets?.get?.(residue.type); + typeBucket?.delete?.(residue); + if (typeBucket && typeBucket.size === 0) this.residueTypeBuckets.delete(residue.type); + const cell = this.residueCells?.get?.(residue._cellKey || this.residueCellKey(residue.x, residue.y)); + cell?.delete?.(residue); + if (cell && cell.size === 0) this.residueCells.delete(residue._cellKey || this.residueCellKey(residue.x, residue.y)); + this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), reason); + return true; + }, + + updateResidues(dt) { + if (!Array.isArray(this.residues) || !this.residues.length) return 0; + this.residueDecayAccumulator = Math.max(0, Number(this.residueDecayAccumulator || 0)) + Math.max(0, Number(dt) || 0); + if (this.residueDecayAccumulator < 0.75) return 0; + const step = this.residueDecayAccumulator; + this.residueDecayAccumulator = 0; + let removed = 0; + for (const residue of this.residues) { + if (!residue || residue.dead) continue; + const before = Number(residue.amount || 0) || 0; + residue.amount = Math.max(0, before - step * (residue.type === "splat" ? 1.05 : 1.35)); + if (residue.type === "splat" && Math.floor(before / 18) !== Math.floor(residue.amount / 18)) { + this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "splat-fade"); + } + if (residue.amount <= 0.001 && this.removeResidue(residue, "residue-decay")) removed += 1; + } + if (removed > 0 || this.residues.length > 512) this.residues = this.residues.filter(r => r && !r.dead && r.amount > 0); + return removed; + }, + noteItemInactive(item, reason = "item-inactive", notifyAchievements = true) { if (!item || item._worldCountedActive !== true) return false; item._worldCountedActive = false; + if (item._achievementPlayerPlaced && item._achievementPlayerPlacedCounted !== false) { + this.achievementPlayerPlacedActiveCount = Math.max(0, (Number(this.achievementPlayerPlacedActiveCount || 0) || 0) - 1); + item._achievementPlayerPlacedCounted = false; + } if (item.type) this.itemCounts[item.type] = Math.max(0, (Number(this.itemCounts[item.type] || 0) || 0) - 1); if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache = Math.max(0, this._activeObjectCountCache - 1); if (notifyAchievements && !this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: -1, reason }); @@ -489,10 +607,17 @@ this.itemOwnerBuckets.clear(); this.carriedPlushies.length = 0; let activeObjectCount = 0; + let playerPlacedActiveCount = 0; for (const it of this.items) { if (!it || it.dead) continue; activeObjectCount += 1; it._worldCountedActive = true; + if (it._achievementPlayerPlaced) { + playerPlacedActiveCount += 1; + it._achievementPlayerPlacedCounted = true; + } else { + it._achievementPlayerPlacedCounted = false; + } counts[it.type] = (counts[it.type] || 0) + 1; if (it.id != null) this.itemIdMap.set(it.id, it); if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it); @@ -512,6 +637,7 @@ bucket.push(it); } this._activeObjectCountCache = activeObjectCount; + this.achievementPlayerPlacedActiveCount = playerPlacedActiveCount; this.itemBucketsDirty = false; this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1; }, diff --git a/js/world_view.js b/js/world_view.js index 2f41c35..c76c8eb 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -339,13 +339,22 @@ const oldH = this.h || height; this.viewportW = Math.max(1, width || this.viewportW || 1000); this.viewportH = Math.max(1, height || this.viewportH || 720); - const requestedScale = fieldWorldScale(this); - const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH); - this.w = this.viewportW * scale; - this.h = this.viewportH * scale; + + // The playable world follows the CSS canvas size. Callers can opt out for + // presentation-only measurements, but normal viewport resizes keep the two + // dimensions linked and may scale existing contents to preserve layout. + const resizeWorld = opts.resizeWorld !== false || !this._worldSizeInitialized; + if (resizeWorld) { + const requestedScale = fieldWorldScale(this); + const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH); + this.w = this.viewportW * scale; + this.h = this.viewportH * scale; + this._worldSizeInitialized = true; + } + this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom); this.clampCamera(); - if (opts.scaleContents && oldW > 0 && oldH > 0) { + if (resizeWorld && opts.scaleContents && oldW > 0 && oldH > 0) { const sx = this.w / oldW; const sy = this.h / oldH; const scalePoint = (p) => { @@ -355,6 +364,8 @@ }; for (const t of this.tarinai || []) scalePoint(t); for (const it of this.items || []) scalePoint(it); + for (const residue of this.residues || []) scalePoint(residue); + this.rebuildResidueIndex?.(); for (const ef of this.effects || []) scalePoint(ef); scalePoint(this.pointer); this.cameraX *= sx; diff --git a/scripts/achievement_audit.js b/scripts/achievement_audit.js index 50b25a5..8ec5df8 100644 --- a/scripts/achievement_audit.js +++ b/scripts/achievement_audit.js @@ -166,7 +166,7 @@ const expectedIds = [ 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.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"); @@ -222,12 +222,16 @@ const expectedIds = [ 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 hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death"); + 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"); } - assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks"); + 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"); @@ -306,14 +310,13 @@ const expectedIds = [ assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "unrelated generations incorrectly counted as a lineage"); h = createHarness(); - const feedWorldA = { time: 0, achievementDirectFeedCount: 0 }; + 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"); - const feedWorldB = { time: 0, achievementDirectFeedCount: 0 }; + h.api.resetWorldProgress(feedWorldA); + const feedWorldB = { time: 0 }; h.api.recordDirectFeed({ world: feedWorldB }); - assert(!unlocked(h, "direct_feed_33"), "direct-feed count survived a field reset"); - h.api.recordDirectFeed({ world: feedWorldA }); - assert(unlocked(h, "direct_feed_33"), "direct feed did not unlock at 33 in one field"); + 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 })); @@ -567,24 +570,6 @@ const expectedIds = [ 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(); - storage.set("tarinai_achievements_v2", JSON.stringify({ - unlocked: { signal_automation_first: 1000, link_craftsman: 2000 }, - pending: ["signal_automation_first", "link_craftsman"], - progress: { linkTypes: ["rope", "rod", "spring"] }, - })); - h = createHarness(storage); - assert(unlocked(h, "mechanized_industry"), "legacy AND achievements were not migrated"); - - storage = new Map(); - storage.set("tarinai_achievements_v2", JSON.stringify({ - unlocked: { link_craftsman: 2000 }, - pending: [], - progress: {}, - })); - h = createHarness(storage); - h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } }); - assert(unlocked(h, "mechanized_industry"), "legacy link-craftsman progress was not reconstructed before a later signal"); storage = new Map(); h = createHarness(storage, "?debug=1"); diff --git a/scripts/achievement_integration_audit.py b/scripts/achievement_integration_audit.py index 9368f61..8496b79 100644 --- a/scripts/achievement_integration_audit.py +++ b/scripts/achievement_integration_audit.py @@ -17,7 +17,7 @@ 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) == 76, 'JS achievement count is not 76') +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') @@ -39,8 +39,8 @@ checks = { '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'), - 'direct-feed field progress resets with world': 'this.achievementDirectFeedCount = 0;' in read('js/world.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, @@ -73,7 +73,7 @@ checks = { '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, - 'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api, + '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, diff --git a/scripts/achievement_save_v8_audit.js b/scripts/achievement_save_v8_audit.js new file mode 100644 index 0000000..5461037 --- /dev/null +++ b/scripts/achievement_save_v8_audit.js @@ -0,0 +1,47 @@ +"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"); diff --git a/scripts/offscreen_motion_performance_regression_audit.js b/scripts/offscreen_motion_performance_regression_audit.js index 45204b7..e359eb9 100644 --- a/scripts/offscreen_motion_performance_regression_audit.js +++ b/scripts/offscreen_motion_performance_regression_audit.js @@ -7,8 +7,8 @@ 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, cadence\.near \? 0\.45 : 0\.75\)/.test(creature), "off-screen scheduled updates catch up bounded elapsed movement time"); -ok(/context\.offscreenCatchup \? 0\.75 : 0\.05/.test(movement), "off-screen catch-up is allowed beyond one rendered-frame dt"); +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"); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index 498cd9b..ee5ab14 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -1675,7 +1675,7 @@ def colony_limits_and_defaults_smoke() -> None: family_social = read_text(ROOT / "js/world_family_social.js") if 'key: "births"' not in charts or 'key: "deaths"' not in charts or "birthEventCount" not in charts: fail("colony population chart birth/death series are missing") - if "inheritedChampionPersonality" not in family_social or "parent.isTarinaiChampion" not in family_social or "* 0.70" not in family_social: + if "inheritedChampionPersonality" not in family_social or "parent.isTarinaiChampion" not in family_social or "* 0.82" not in family_social: fail("champion personality inheritance is missing") inheritance_block = family_social.split("function inheritedChampionPersonality", 1)[1].split("(function (global)", 1)[0] if "isZunchiSlave" in inheritance_block or "zunchiSlaveLocked" in inheritance_block: diff --git a/scripts/reproduction_memory_sprite_regression_audit.js b/scripts/reproduction_memory_sprite_regression_audit.js index 2ba7cd3..5ee5f74 100644 --- a/scripts/reproduction_memory_sprite_regression_audit.js +++ b/scripts/reproduction_memory_sprite_regression_audit.js @@ -14,7 +14,7 @@ const render = read("js/render.js"); const tarinaiRender = read("js/tarinai_render.js"); const history = read("js/history_system.js"); -ok(/reproductionCooldown:\s*30\b/.test(data), "reproduction cooldown reduces the current reproduction pace by about 30 percent"); +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"); diff --git a/service-worker.js b/service-worker.js index 592af65..9a4f2a9 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "39.16.109"; +const APP_VERSION = "39.16.137"; 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. 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_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", "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", "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 CORE_ASSETS = [ "./", From 091afc2b5c8b79bdc3c7f04dfbeb48097b55fa25 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 29 Jul 2026 20:52:11 +0900 Subject: [PATCH 3/4] asdfg --- .achievement_data/state.json | 1 + .gitignore | 9 - .htaccess | 15 +- .vscode/settings.json | 3 + ACHIEVEMENT_AUDIT.md | 268 -- AI_MAP.md | 112 - BUILD_NOTE.txt | 1 - FILES.json | 191 -- GAME_FEATURES.md | 93 - README.md | 95 +- achievement_api.php | 1564 ++++++--- app_manifest.json | 3 +- assets/ui/pwa-icon-192.png | Bin 0 -> 26366 bytes assets/ui/pwa-icon-512.png | Bin 0 -> 119075 bytes css/mobile.css | 20 + index.html | 373 +-- js/achievement_catalog.js | 724 ++++ js/achievements.js | 1975 +++++++---- js/circuit_board_system.js | 93 +- js/domain_ids.js | 4 +- js/game_dialogs.js | 13 +- js/item_environment_hazard_system.js | 2 +- js/item_lifecycle_step_frame.js | 2 +- js/mechanical_system.js | 4 +- js/placement_preview_system.js | 8 +- js/save_codec.js | 158 +- js/save_schema.js | 4 +- js/save_system.js | 29 +- js/signal_system.js | 60 +- js/snapshot_system.js | 239 +- js/structures.js | 1 + js/tarinai_direct_feeding_system.js | 21 +- js/tarinai_update_policy.js | 34 +- js/ui_bind.js | 12 +- js/ui_ground.js | 2 +- js/ui_layout_dialogs.js | 84 +- js/version.js | 4 +- js/world.js | 10 +- js/world_event_effects.js | 6 +- js/world_placement_log.js | 55 +- manifest.webmanifest | 27 + router.php | 24 + ...ievement_audio_tooltip_regression_audit.js | 22 - scripts/achievement_audit.js | 709 ---- scripts/achievement_integration_audit.py | 128 - scripts/achievement_save_v6_audit.js | 47 - scripts/achievement_save_v8_audit.js | 47 - scripts/achievement_server_state_audit.py | 341 -- scripts/ai_inventory.ps1 | 124 - scripts/ai_inventory.py | 212 -- scripts/colony_crisis_regression_audit.js | 44 - scripts/effect_runtime_regression_audit.js | 18 - scripts/generate_app_files.py | 52 - scripts/generate_item_icons.py | 146 - scripts/history_binary_regression_audit.js | 46 - .../long_session_runtime_regression_audit.js | 84 - ...on_tarinai_performance_regression_audit.js | 21 - .../object_render_cursor_regression_audit.js | 25 - ...een_motion_performance_regression_audit.js | 16 - scripts/pathfinding_regression_audit.js | 58 - scripts/performance_5to7_regression_audit.js | 58 - ...rformance_architecture_regression_audit.js | 28 - .../performance_cleanup_regression_audit.js | 77 - scripts/playstyle_achievement_audit.js | 232 -- .../population_recovery_regression_audit.js | 194 -- scripts/regression_check.py | 2944 ----------------- scripts/render_stack_regression_audit.js | 16 - ...oduction_memory_sprite_regression_audit.js | 26 - scripts/reproduction_regression_audit.js | 27 - .../secondary_achievement_regression_audit.js | 119 - scripts/self_zunchi_regression_audit.js | 129 - .../social_interaction_regression_audit.js | 23 - ...ltip_nail_colony_focus_regression_audit.js | 23 - scripts/ui_grass_limits_regression_audit.py | 85 - service-worker.js | 169 +- start_server.bat | 6 + start_server.sh | 5 + 77 files changed, 4413 insertions(+), 8231 deletions(-) create mode 100644 .achievement_data/state.json delete mode 100644 .gitignore create mode 100644 .vscode/settings.json delete mode 100644 ACHIEVEMENT_AUDIT.md delete mode 100644 AI_MAP.md delete mode 100644 BUILD_NOTE.txt delete mode 100644 FILES.json delete mode 100644 GAME_FEATURES.md create mode 100644 assets/ui/pwa-icon-192.png create mode 100644 assets/ui/pwa-icon-512.png create mode 100644 js/achievement_catalog.js create mode 100644 manifest.webmanifest create mode 100644 router.php delete mode 100644 scripts/achievement_audio_tooltip_regression_audit.js delete mode 100644 scripts/achievement_audit.js delete mode 100644 scripts/achievement_integration_audit.py delete mode 100644 scripts/achievement_save_v6_audit.js delete mode 100644 scripts/achievement_save_v8_audit.js delete mode 100644 scripts/achievement_server_state_audit.py delete mode 100644 scripts/ai_inventory.ps1 delete mode 100644 scripts/ai_inventory.py delete mode 100644 scripts/colony_crisis_regression_audit.js delete mode 100644 scripts/effect_runtime_regression_audit.js delete mode 100644 scripts/generate_app_files.py delete mode 100644 scripts/generate_item_icons.py delete mode 100644 scripts/history_binary_regression_audit.js delete mode 100644 scripts/long_session_runtime_regression_audit.js delete mode 100644 scripts/non_tarinai_performance_regression_audit.js delete mode 100644 scripts/object_render_cursor_regression_audit.js delete mode 100644 scripts/offscreen_motion_performance_regression_audit.js delete mode 100644 scripts/pathfinding_regression_audit.js delete mode 100644 scripts/performance_5to7_regression_audit.js delete mode 100644 scripts/performance_architecture_regression_audit.js delete mode 100644 scripts/performance_cleanup_regression_audit.js delete mode 100644 scripts/playstyle_achievement_audit.js delete mode 100644 scripts/population_recovery_regression_audit.js delete mode 100644 scripts/regression_check.py delete mode 100644 scripts/render_stack_regression_audit.js delete mode 100644 scripts/reproduction_memory_sprite_regression_audit.js delete mode 100644 scripts/reproduction_regression_audit.js delete mode 100644 scripts/secondary_achievement_regression_audit.js delete mode 100644 scripts/self_zunchi_regression_audit.js delete mode 100644 scripts/social_interaction_regression_audit.js delete mode 100644 scripts/tooltip_nail_colony_focus_regression_audit.js delete mode 100644 scripts/ui_grass_limits_regression_audit.py create mode 100644 start_server.bat create mode 100644 start_server.sh diff --git a/.achievement_data/state.json b/.achievement_data/state.json new file mode 100644 index 0000000..6939f24 --- /dev/null +++ b/.achievement_data/state.json @@ -0,0 +1 @@ +[4,1784091728,["39.16.75","39.16.105","39.16.123","39.16.60","39.16.74","39.16.137","39.16.104"],["first_birth","natural_zunchi_slave","natural_tarinai_king","self_zunchi_death","zunchi_collision_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"],[["AT7UX0vQQHSjDSsx7ntrcA",115523,0,4],["AXHGXDetQkWuJ2vrhLVrsw",191206,599],["AZOq9iLdRLiVs_J1HNcdNg",26632,311131,1],["AgioecJAT9-DyKM9rBWwFg",354351,233,1],["An-kM9_1Rkuiu0eIHnJROw",24687,416999,5],["A9UhC-axRtWKy4Vw8NRLjA",225077,15],["BmyezTENRtm5ekkH3z99qg",289868,146256,5],["BsqXI2J_RTCLzl9rK-WTVQ",201094,16],["B5myaZslQwSnpkvoGpknsA",37613,1800,3],["C0tajnOTT1K0FgIfEGVsGA",256959,2973],["C4GRUxmdRbWZzHJ0V9JpsQ",17432,357474,2],["C8XqqJ5rTHa6qw-z8Y88jA",365330,744,1],["DFKBeF9-RpKfjhRhQx2ZRg",174267,14974],["DGPMjLtqRaCtHIptLtkjdw",221295,2546],["DyaOro6TQ5G_TeHnltFK0g",67643,146960],["EGSebCuwSk6FKdPePBeDOg",118168,1199,4],["EgkHNUQJQPat3FM4ZIU59w",156003,0],["EyToTt3-RX26J-6eJ-xpPw",191733,6600],["E0HXsU6-RYCO91MK9M07bw",9762,104722,4],["E2ldSdcxQN-zZ9xTBzDE1g",368929,299,2],["Gr93GoiFQkGMHKs2Mw4AaQ",97655,9130,3],["G2o3yC0MTIKMLhDk2o6YDA",245190,7797],["HYkDqmCsS8aiMrUThI4fcQ",201617,13],["Hns48Q0MQJ2H-lFEmwxfQw",23523,603,3],["HwT0Sk-XSGahzteQS7FPzQ",287737,92777,2],["H5ZDYEFvQ0e-3JC0xPTx-Q",27702,421,3],["ITRYJzo5QYaSkjLYaaJ6Yg",40636,46882,3],["ItNN0heHRqiNHc-OoGoSMA",297825,1298,1],["JuwFpfb1QJeYfGvWpzy3IA",63369,126598],["J7uf1v88T3mSFs1ptKO9RQ",120239,25],["KSZ59taVRJmYmVYVhe99hg",77159,1800,3],["KwJlifxVSh-GUDpUNABzrw",369833,308,2],["LDAWke6kRbutWe3r9dfLdQ",318649,776,1],["Li2NtIyOQpGHzFaEovEoYg",100406,0,4],["MEMut_ufQsmx1rPi45G81Q",36627,395276,2],["Mpv6WJHGTw6YwQQ70mk7BA",13479,80,3],["Nf-0jB-JQ0-UZnRWPHS98A",300640,4006,1],["Nhx8JhVfTOWAYRt1vRuQow",113724,74645],["NjK9LbETSzOX-LpfqL0nxg",255226,103273,1],["Nj7jeXEWTXa6Sm5VPRlmEg",206684,123],["NkwzEL98Rzi4GwdSd5A3ng",20301,0,3],["NuaRm_fYR7Sz8nVs4P4yCg",114763,34,4],["Nvzk_2oQQbawaZca4Pww2Q",291531,73544,1],["NxW5_hQ1SoO5iSuv66RCfg",270726,299,1],["Nx2nfW9iRpeJXcFw7IOdIA",283823,976,1],["QD38IUY8RJeCcN0xwmTyTQ",269572,9325,1],["QQx3jG6FR2-TJx80sIlCUA",241410,16],["QoCQg7HPQYKYtzZY6V5tTQ",375227,2400,2],["Q-YwoP68T6anCkkhU6gLAg",234541,3300],["RB-Tlne5T4OxgjAQO0_ysg",381794,740,2],["RB_DGMVqRV-QbX2uFDTRpA",365383,1322,1],["RVu0RBgqRbipwYNU50e9qQ",343860,0,1],["RrNxNSRsSiKVxmeMQo6lPg",433513,222,2],["R6bORyS0QYGxKejqyoB10A",303512,14,1],["SG0ktBVwR5Os_MDzYJoawQ",39040,599,3],["SfZYmhpyRZCwrqBiw9kqUA",132898,357],["SjYuiyoHTeivYcMyKoRGNg",337249,104061,5],["S5DpVCRaThahOqTSMlWPDA",89164,337690,2],["TUKrrmrET2ypgp5DaU4tlA",18426,0,3],["T9GkyS2HSqKbKoqMiC7WXg",47094,16,3],["URXE_032StGQlEl3V17HXQ",374574,2899,2],["UdLsI4uyQ_S-XN7a4N5CvQ",34688,392712,2],["UqaJ9nvhQl-eT-Bey1m8Rw",16731,420407,5],["U2UehYHxQ5248yI8fy7_iw",145756,39],["VRG1ggLhStGW9MXAb65Btw",31668,17,3],["WQ3Ku6CJTWaCLabPhA951w",363389,625,1],["W_G-e_6AT4qICRL-Hz05sA",119138,14,4],["XNVK7HGDTMiNp8dsEPgM2A",52741,13,3],["XdDGg7MrTG2ZNpPIVpk3VA",119422,14,4],["YIXXT1hTSrS0ESGYH59www",422182,1798,2],["YKJXT2URTSydN9aUmkJOKA",179869,1221],["YYaufcmaQ4GiD9Q_MZuMWw",299654,335,1],["Yads3JcpTMymeKumAQcF6Q",362480,16,1],["YkBYIYwHRYOwkX0fmeqT4w",249642,299],["ZBBiN426T3WEYxVBXituGQ",90719,15103,4],["ZHedzwwERgmbHhpZuIEoeg",110056,94,4],["ZO4ZHBKxRyu5IlOdEiaMjQ",173877,0],["Z2KTakqhSTuWysEEAKyGXA",195554,109463,1],["a7UWcDOyToOQycGTu33MeQ",214800,148558,1],["bFoT0vGfRSapP6saKj_L6g",423544,3600,2],["bP42-AJoR4CDwWnCKWHocQ",232663,300],["bcxw7HujQFi5Y1YOMwQOfA",121952,23511],["b-Q9bhMZTHGa_blAGuZseQ",341953,599,1],["cnupw5mWSfS4hwLnV1L2XA",248428,106847,1],["csNqBKPtRNe25Pr757SKaA",203517,9051],["dQ3Y_1tPRZ6oPIRWPKo4aQ",194363,0],["dqOnFoBXTlqwDr8C2mv5yw",25033,404829,2],["eKNNK69YS6-iu0qhfQ6p9A",125751,14],["eifTn_kTRgSMWj6KYbalIg",100380,14,4],["fGJd8BQqRkai1XTM6B7nEw",13459,279970,1],["fJLe4FHgS2O9b-hFtG9Ldw",344682,594,1],["gAlvRzRXTLOxqgdwcRxLQw",421603,301,2],["gMC_86VbT7uIH8B0fdKxlA",14550,0,3],["gVlK1ilNRPeMIPUAA-JRtQ",221160,13],["gcRLtucSTrGWq7WYznfTmQ",290969,899,1],["i8Ff_VXpTFORsOhh4jZHbQ",280665,51,1],["jAw6RpvST3OEMAvbeH77LA",240387,221],["j-mNs4bdSI2XqAhGb4p3Hw",88836,20,3],["kBFvt4RRSai3DmGuyfgf6w",19817,2574,3],["kUEPr6JJTz6mCwWJDLv-aQ",115199,2400,4],["mlBIJs6mT52AX2Ahpgh7sw",196651,3934],["mswKVgE5Qxqta4NAHvVkoQ",437951,14,5],["nTWtsnADSv648AxbFz4Lcg",212355,9300],["nVUbi4OmT6-J9byg8-q97g",32001,1123,3],["nxblVyMLTrSZAkfPf2FWwg",383336,26486,2],["n-SY48K2QeqzCkb7yTZPpQ",259675,1200],["n_tcf3j7S6yBYt2Y7yM85A",293624,169,1],["odJ_h7oUS1yB_7p6LB4w7w",112982,342,4],["oe139e_bR4WtnCcqSe2jBQ",332048,0,1],["o4yHwSx2SX24OE9qPkJHsw",432488,825,2],["pQFs21PfTXuXeaf1tEVdMQ",129750,4545],["qTwJtmQESIC3jAtmz_8sTg",423097,13,2],["qnK6RnesT2igW_HcZssIdA",97976,334829,2],["qs3rnavAT4acYZvaEeI5oQ",129913,1199],["qxGFhkHZTduV9YYYWyDSUg",147480,273846,2],["r75L6GPkR4uVqBqO_1aSyA",355137,2246,1],["sSCrwsqiSyqBVgz7Matzww",299588,101812,2],["s3O6OZTBRz6O0TUBQC64QA",23489,245,3],["s_uTFGOTQdKTmWoJ9Vbzpg",24710,357292,2],["tMDzFziXTJ2CYoBLWq9H9Q",8579,433201,5],["tWF1yvWnTfOaaaBAbXwtPA",29003,17,3],["taU6vA6PTjWhKC6V43YplA",24383,411287,2],["t2UqLSrEREKy0Ai8JSYKGQ",203662,113],["t7tpQzqNRV6-tLSbunsDfg",302098,133,1],["t_SyOC8gTi218xAe4vOGgw",382434,7684,2],["uAtfx_GBRvmJCP6E9UWmzg",100421,15175,4],["uU2jxY3bRBe7AtfHRFGSIA",34810,299,3],["uqFbOGx4S_6ZvjcQrWE2Bw",26634,346405,2],["v1K6sNSCRdWXvmk8Jl91sQ",20955,298322,1],["wBeR6TKiQ1SnWlxO6HjxUg",208309,1199],["wF4aj-hSTl-BSuCbVRywOA",429094,2999,2],["wK3oJUoHT5Kb0jeLIzFJJQ",184790,16],["wM8XiYo0R9SEcCkDjO2bxQ",390457,1499,2],["wQwjJN_pSSOryn69Y8lqyA",16767,418804,2],["when_J1OTpScd-vaA2i6xw",341088,309,1],["xhjNabCzQHGEWRw9eEFAYQ",101690,275766,2],["xnEBJGKVSSC7--M0K_e2JA",364391,1764,1],["yYHnh-RxQaOcASVbQj6gOg",297672,2435,1],["yp2M01VvTle5xgXu7ZNzqg",204919,14],["ys4oahR7TDm9WW5zOKyShA",30073,1310,3],["zJtKDIeiTM2oegMGMt3nvg",343271,0,1],["zLFzVsWhSTOYU6rJsMsi1Q",20602,1472,3],["zOC7w26xQYiGnLQy3x504A",57982,44,3],["zmEzLb-1Q6m08t7dLiqFdw",11424,599,3],["zn1ecYMwRNKrJukPfPFoXQ",103760,282772,2],["zsuxu35fRq6bIZCvcC3iag",106311,285,4],["0Ekuj3W8Qpm_EwKpSXYdXw",377873,19,2],["0KpzjsoqQYaN_S2X8ySd7w",22236,352889,2],["0ia10T1pSZmAKukutrEaIA",31969,87661,4],["0lcgISlzQEej3cvhI-e-Ww",289089,82410,2],["0y4SH2FXRYGK1d0FCkOUjw",378025,6741,2],["00Z1M7PFTqq9iwnnCxiZTA",269597,166,1],["1dKF4NRHRfaJtfQr4lafCg",40381,1201,3],["2Bdkm5NaSY2lY1VMj_1Mqw",69602,16,3],["2jtrVkYNQtGpp7Y2wSOwZQ",304589,74,1],["2rseIRu9RAuvUuNOsSO_-Q",26626,18,3],["28TEqRx1QuK-TmIYSLzL1Q",34802,0,3],["3E-4jv0QSPq33RbRg4B3Aw",423829,7130,2],["3XJWUCjdRgSxmd5Z1hfDcw",16826,420725,5],["3cXtHb7yRQSBcQK7yx-_zw",232365,465],["3mSl6ER2RtSj9a0p-sNDMA",430880,299,2],["4JrymWEKThuc99N1_G1_rQ",11020,430720,5],["4XM-EZ63SiSRpbvcIqdZyw",133745,183813,1],["4u-CXB7uTwuNWoFuQ2ZO9g",196903,14],["5aQrgazaTe2oNgyFnFVOFA",25453,8280,3],["5c9NjVEiSOGVeFiQ9tdHYg",217415,3322],["5woLhtEbSPuhPazSSQoluA",197842,0],["6kdvBVYWSuuyehSCCPGLYw",11195,237,3],["6ryKJq12TO-2vFdLKAIPXw",100122,163,4],["7587mqPLR3iJGXGBXjcDIQ",194177,56975],["8M_zxblPSkGw-_mE0gJUXQ",364739,2101,1],["9OZ_7xz9SkS56dk0cpJsLA",279830,659,1],["9UnXYYzZSYiFRDOzfNeCvw",264541,6248,1],["9gX-Lr9aTTKqMIA4Mb5MGg",28665,254163,1],["9pgiN8b5R8CzMX_G28g_Xg",388466,3300,2],["9uEITVHeQXegRVD6lXnz8w",297708,5964,1],["90eBgHt_S0Gai4x7q3f8eA",10519,253386],["92hhaxj2R1mlus6Ck9XBPQ",268366,15,6],["-Haa3Cb8RzO-mee5e4tggA",147060,1287],["-Rrwi9qrTv2pEbUgjIPivw",142099,14],["-3NMp6qyR9q05KN8ZX48Dg",33123,33796,3],["_MZl_4kLSDGUuDuNMl1-6g",369100,2265,2],["_QIygAxARMO5xTL3TO8SKg",90452,14,3],["_66f6qhySiOVSH5VcwD-kQ",293656,137,1],["_7nqDabvQF2tbBmkp4eb6Q",386660,0,2]],[[0,1,191271,2,26643,3,354359,4,24701,5,225088,6,289885,7,201106,8,37620,9,256968,10,17452,11,365338,12,174277,13,221305,14,67654,15,118185,17,191745,18,9776,19,369026,20,97667,21,245351,22,201626,23,23539,24,287749,25,27715,26,40652,28,63416,29,120261,30,77482,31,369844,32,318670,34,36639,35,13497,36,301783,37,113723,38,358493,39,206694,41,114776,42,291542,43,270735,45,269660,46,241420,47,375235,48,234562,49,381817,50,365395,52,433544,53,303521,54,39051,55,132912,56,337258,57,89223,59,47106,60,374592,61,34699,62,16742,63,145791,64,31682,65,363398,66,119148,67,52752,68,119433,69,422209,70,179877,72,362490,73,249681,74,90750,75,110092,77,195567,78,214810,79,423559,80,232673,81,121976,82,341963,83,248473,84,209959,86,25044,87,125762,88,100391,89,13473,90,344691,91,421843,93,221169,94,290981,95,280684,96,240401,97,88854,98,20420,99,115231,100,196666,101,437964,102,212360,103,32024,104,383388,105,259692,106,293635,107,112992,109,432574,111,423110,112,97987,113,129925,114,147489,115,355186,116,299605,117,23525,118,24722,119,20,120,29018,121,24421,122,203672,123,302108,124,382456,125,100417,126,34821,127,26661,128,20967,129,208332,130,429106,131,184801,132,390470,133,16796,134,341114,135,101701,136,364515,137,297724,138,204929,139,30097,141,20613,142,58021,143,11514,144,103781,145,106348,146,377892,147,22247,148,31993,149,289102,150,379181,151,269607,152,40388,153,69615,154,304597,155,26641,157,424098,158,16839,159,232374,160,430897,161,11034,162,192807,163,196695,164,25878,165,217502,167,11258,168,100136,169,194187,170,364747,171,279840,172,266280,173,28677,174,388528,175,297712,176,10531,177,268378,178,147110,179,142110,180,33135,181,369117,182,90463,183,293665],[1,1,191403,2,26877,4,25011,6,290244,8,38012,10,135101,11,365564,12,174521,13,221461,14,70866,15,118434,17,194565,18,10436,19,369151,21,245755,25,27750,26,40966,28,63838,30,78953,31,369987,34,271072,36,302091,42,292625,45,269918,47,375662,48,234981,49,382343,50,365969,56,337683,61,35564,62,17369,65,363850,70,180343,73,249848,74,93618,77,281773,78,214997,81,145234,83,248720,84,210906,86,25873,89,13762,90,344778,98,21616,99,115359,100,196869,102,214588,103,33124,106,293788,109,432827,113,130388,114,419890,115,355381,116,300912,118,24984,119,25211,124,385030,126,35004,128,21266,129,208665,130,429479,135,377244,139,30472,141,20849,144,290828,147,374711,149,371387,150,380038,157,425942,158,17350,159,232638,161,11255,169,251142,172,266492,173,200146,174,388714,175,298220,176,116422,180,33438,181,370575],[2,1,191761,2,26936,4,25652,6,290128,10,135093,11,365556,12,174506,13,222661,14,204836,15,119256,17,194663,18,112037,21,245876,26,41195,28,63729,36,302040,42,292786,45,269974,47,375721,48,234981,50,366237,56,337503,61,35520,62,17526,65,363886,70,180169,74,96379,77,282412,81,145315,83,248754,84,211047,86,25686,89,13752,90,344804,99,115637,100,197049,102,216835,113,130997,114,419917,115,355380,118,25605,119,25371,124,383289,128,21230,130,429474,135,377269,139,30496,141,20917,144,385343,150,379732,158,17595,159,232666,161,11354,171,280197,172,266472,173,200160,174,389034,175,298714,176,116572,181,370591],[5,74,101510,89,120349,128,22252,173,200261],[6,2,27153,12,174657,14,207834,89,13796,128,21666,173,200632],[7,1,191426,4,26737,6,290782,8,38242,9,259927,14,83478,23,24000,25,28120,30,78409,32,319011,34,38178,37,188014,43,270914,47,376773,49,382530,54,39411,61,35165,62,18098,74,91322,77,283158,81,122210,82,342359,83,249109,89,15076,102,212507,104,408595,105,260129,115,356759,121,25152,124,382604,127,27380,128,55608,129,209435,133,108888,135,147785,136,365613,137,299395,141,21485,149,289208,152,41570,157,424893,158,17870,161,280009,162,235865,170,364915,172,268642,173,113688,175,297802,176,10914,181,369796],[8,1,191304,2,27344,4,24911,6,290372,8,37848,9,257230,11,365879,12,174714,14,67718,17,197179,18,113098,21,245454,23,23612,25,27763,26,41186,30,78269,32,318871,34,36704,37,187874,42,294092,47,375996,48,235366,49,382263,54,39237,57,89275,61,34845,62,16821,74,90789,77,195597,78,294671,82,342519,83,248882,86,25562,89,14016,90,345085,100,198235,102,213131,104,408208,105,259742,112,98505,115,357048,119,267855,121,24568,124,382481,127,26894,128,22228,129,209359,132,391324,133,108196,136,365559,137,298989,141,21239,148,114644,149,290977,150,379324,157,424728,158,17014,161,11623,162,235545,164,27402,165,217591,167,11431,172,266558,173,28743,174,390487,175,297945,176,10852,181,369155,183,293764],[9,4,24947,14,124595,21,251727,34,254051,89,204437,121,24568,133,108196,158,20008,172,268084],[10,10,374646,11,365856,18,29044,21,246093,36,302622,42,293318,45,270168,61,35664,70,181083,74,94416,77,282919,84,211479,89,14055,99,116043,102,218148,115,355704,116,303469,124,384432,128,55963,130,431869,157,430786,158,24354,172,265911,173,282338,174,390520],[11,4,26736,17,192182,34,120809,36,304372,47,376752,48,237261,49,382325,61,426657,62,17177,74,92754,77,283423,83,249108,100,197742,102,213730,112,98720,114,420746,124,385160,128,318806,136,365447,137,299353,144,289095,149,290481,158,20965,160,431153,161,280037,172,264711,173,114102,175,300912],[12,1,191425,4,24722,6,290787,8,38527,10,374316,14,121713,17,196675,18,107315,20,97778,21,252728,23,24000,25,28120,30,78592,32,319011,34,108438,36,303508,37,113790,42,291912,47,376820,49,382397,50,365551,54,39407,56,337608,57,90476,61,206402,62,16919,74,91648,77,281947,78,294736,81,122083,82,342093,83,353761,84,211929,89,43353,94,291137,96,240608,100,197078,102,212515,103,32514,105,260264,107,113267,113,130585,114,419848,116,300962,124,382645,127,27423,128,55609,132,391032,133,108887,135,147784,136,365614,137,299469,143,11706,144,104301,145,106594,147,374763,149,289257,150,384351,151,269757,152,41479,157,425607,158,18012,161,110764,162,235879,168,100284,172,270247,173,113699,175,297801,176,10593,178,147214,181,370718],[13,4,24758,14,71230,34,242398,48,235358,61,35471,74,98685,133,108887,158,78115],[14,45,273422,78,215806,84,204504,110,132675,116,401030,124,383690,130,431406,136,365236,158,74280,175,298584],[16,4,26004,8,38458,14,210165,17,196587,18,10744,20,98085,25,27833,42,291833,47,376752,48,236419,54,39421,57,303665,62,18071,74,91316,77,301630,78,294675,81,122023,83,355044,94,291093,96,240572,102,213445,104,383590,115,357109,116,398245,118,380402,124,384746,128,56573,135,376985,137,299172,148,32382,157,425513,158,17750,161,11805,175,297873,181,369640],[17,3,354469,4,25296,6,290190,12,175855,14,103020,17,194491,28,64429,30,78286,31,369999,34,182984,54,39586,55,133085,57,302318,61,207064,62,179559,74,92764,77,282434,78,286253,82,342039,83,248781,89,42902,100,197667,102,212649,109,432657,112,98249,113,130070,114,420504,116,300601,118,25397,121,26268,124,383302,126,35081,130,431627,132,391584,136,365374,137,299921,143,11632,157,429460,158,21084,161,11375,173,113883,175,300479],[18,4,25332,10,135105,11,365623,12,175107,14,71139,17,196976,18,112078,21,245597,36,304490,42,292808,45,272914,48,234998,56,346890,61,206011,70,180164,73,249874,74,99353,77,284595,78,215864,81,145286,86,429778,89,13788,90,344982,98,21613,99,115373,100,198070,102,212467,105,259888,115,355780,119,25912,121,26268,124,388453,128,21255,130,429684,141,20897,144,384877,149,291763,150,384742,158,24245,162,288310,165,217575,172,267542,175,299709],[19,1,191505,4,25153,6,290163,8,37859,9,258622,13,222651,14,124567,17,195544,18,107317,21,251485,23,23627,25,27916,28,188921,30,78400,32,319421,34,36844,37,187526,42,292712,47,376156,48,236882,49,382399,50,365450,57,302393,61,34956,62,17794,74,91304,77,281875,78,363130,81,122066,82,342083,83,249043,84,208552,89,43348,96,240575,102,212853,104,408403,105,260336,109,432658,113,130646,114,419593,116,300144,118,25369,119,267176,121,24824,124,382580,127,26983,128,57118,129,209100,130,431893,132,391901,133,108754,136,365494,137,298902,139,30309,143,11760,144,288492,145,106522,147,374823,148,116020,149,289201,152,40754,157,425760,158,17343,161,279984,170,366547,172,266672,173,113460,174,390196,175,297850,176,116964,181,369630],[20,4,25189,121,24824,124,385693,133,108754],[21,2,26996,61,427115,89,195346,157,428408,158,263423,162,287847,172,267224,173,281557],[22,14,123728,17,197312,18,107323,21,252670,47,377566,62,17096,74,91943,77,281723,83,354448,100,198850,102,213072,107,113311,116,304590,124,382583,127,372792,132,391115,136,365580,137,300102,150,384682,152,41478,158,22009,172,269722,173,201023,175,297768],[23,74,92072,77,302595,102,213092,124,384683,158,106247,172,269734,175,300252],[25,1,191404,14,210559,74,103507,77,282901,78,214910,82,342291,83,249655,102,212392,124,384532,127,112599,133,108407,149,289120,175,298129,181,369126],[26,12,185477,14,109479,17,195329,21,248783,42,295126,45,273163,56,346839,74,96996,77,208550,84,207107,89,110513,102,215942,110,134283,116,304075,119,110526,121,114968,124,386037,128,60621,133,108407,144,386532,148,117825,150,381627,157,429459,158,25016,175,303068],[27,61,36271,121,114968,124,383305,133,434195,139,30359,149,290550,175,302397],[28,102,216662,124,382614,175,297813],[29,1,191381,2,26831,4,25093,6,290187,8,38238,10,296441,11,365547,12,174667,13,223097,14,71264,15,118514,17,194670,18,10718,19,369203,20,98030,21,245525,23,24040,25,27853,26,41154,28,63954,30,77655,32,319016,34,36880,36,302068,42,291863,43,270947,45,269762,47,375812,48,235044,50,365896,54,39410,55,133254,56,337622,57,89600,61,35319,62,16966,65,363856,69,423612,70,180530,73,249892,74,91305,77,281807,81,122103,83,248967,84,211240,86,26562,90,344834,94,291135,96,240590,98,22085,99,115666,100,197074,102,212540,103,32485,104,383811,107,113262,109,432692,112,98804,113,130302,114,419910,115,355419,116,303556,118,25022,119,526,121,25203,124,382636,126,35057,127,26920,128,21320,129,208926,130,429477,132,391434,133,109367,135,377050,136,365057,137,298037,139,30386,141,20839,143,11694,144,103933,148,114947,149,289297,150,379630,152,41539,157,424904,158,17243,159,232762,161,11410,162,287928,164,28962,165,217992,170,365437,171,280205,172,265906,173,28954,174,389148,175,297884,176,10921,181,369297],[30,4,25129,121,25203,133,109367,161,369733],[31,10,374525,12,176382,13,221895,14,126836,18,10417,21,246095,25,28086,30,78065,32,319287,37,188289,42,292307,45,270035,47,377041,50,366703,56,338039,57,90141,61,207037,70,180845,74,93795,78,215776,83,249727,84,204362,89,107481,99,115724,100,199494,102,214010,110,130215,114,420164,115,356298,116,301335,119,0,124,383030,127,27602,130,429954,136,364827,141,21112,157,427514,158,72072,170,366834,171,280483,172,265218,175,300421,176,263900],[33,1,191776,10,296506,11,365621,34,38079,36,302337,42,293115,47,375844,62,59047,65,364009,77,282254,81,145459,115,355837,118,122093,124,387234,128,21571,130,429714,158,19455,173,200446,174,389493],[34,8,38404,25,28065,57,302660,61,34870,74,92335,83,250320,102,213816,105,260325,116,300730,124,386317,144,289116,158,22018],[35,4,25269,6,290038,8,38275,13,222496,17,195112,18,10633,25,27809,28,64123,30,77500,34,254918,36,303374,42,360305,45,276602,48,235550,57,303544,61,36191,74,92915,83,248885,94,291731,99,115630,100,199950,102,219571,113,130782,116,401290,118,25594,121,432761,124,383293,127,27750,128,318631,132,391465,137,298306,139,30513,141,21378,148,118915,157,424086,172,270124,174,389200,175,299106],[36,4,25305,121,432761],[37,57,302377,74,92225,102,213565,119,261,124,382877,158,22256,172,266074,175,297740],[38,45,272277,74,94416,84,211479,102,218148,124,384432,158,110089,172,265911],[39,74,97963,77,281739,102,213091,124,382613,136,365592,158,173209,172,269542,173,281466,175,299583],[40,74,105068],[41,4,25170,6,289906,12,174728,14,67955,17,195667,18,113027,25,27722,50,365707,54,39168,57,90303,62,17228,69,423455,74,93501,77,301283,89,15351,91,421743,95,280710,99,116177,102,215121,103,32636,109,432665,113,129984,115,356679,116,400156,121,25726,124,384916,132,390794,135,377118,136,365993,139,31379,143,11571,145,106322,147,22462,158,17208,159,232523,161,284364,162,235523,173,113389,175,299541,176,116451],[42,4,25206,74,94427,102,216172,121,25726,124,384484,175,297822],[43,124,384494],[44,2,27065,4,24998,8,37896,9,258677,10,296542,11,365657,12,174379,14,67808,17,195704,18,112952,21,245429,23,23733,25,27777,26,41035,34,36980,36,302570,37,187908,42,292948,45,269903,47,375938,48,235017,54,39380,57,304103,61,35232,62,16865,74,90988,77,282572,80,232782,83,249042,86,25894,89,13885,90,344901,100,197788,102,214872,105,259819,112,98560,114,421048,115,355470,119,268540,121,24838,124,388670,127,26880,128,21985,129,208847,130,430084,141,21325,144,385312,148,116181,150,381354,157,425400,158,17056,161,11575,162,235759,164,28303,165,218031,172,266620,173,28827,174,389351,175,299217,176,263299],[45,4,25034,50,365924,74,105201,83,355113,99,115884,110,130804,112,98230,121,24838,137,298117,158,22765,172,265327],[46,137,299323,158,22601],[47,4,24994,13,223105,15,119001,28,189088,32,319329,48,235877,50,366471,56,337483,61,426414,74,90941,78,285331,83,249081,86,25500,89,43893,90,345028,102,213712,115,355381,116,400930,121,284262,124,386720,128,56235,129,209262,137,297767,144,385207,157,426525,158,21129,161,284250,172,265124,180,33620],[48,4,25030,49,381894,60,374700,69,423497,74,97521,75,110145,80,232716,102,213657,118,121940,119,30130,121,284262,141,22071,161,369609,169,250908,171,279948],[49,14,204027,102,221572,158,17815,175,299731],[52,42,291797,61,36074,74,97029,83,354951,102,213336,127,373039,128,318977,144,380529,173,281527,175,298219],[53,74,93052,83,353710,129,209051,158,255175,172,269548],[54,14,119904,21,248936,34,108044,61,207416,74,95588,89,14524,128,58042,157,427025,158,18504,172,268247,175,301597],[55,1,191428,2,337581,4,441091,6,290458,9,257426,10,374408,12,174844,14,118370,15,118168,17,192183,19,369078,21,245638,31,370134,32,319005,34,101422,36,301967,37,188018,42,291882,45,270154,47,376752,50,365565,60,374729,61,206787,71,299651,74,103186,77,283094,78,294682,81,122391,82,342350,84,203514,86,429769,89,192464,90,345160,99,116281,100,196668,102,212862,104,383348,105,259936,114,420753,115,355426,118,380896,121,111372,124,382565,127,372529,128,317960,129,209107,132,390869,133,107924,136,365542,137,298946,144,384220,148,114234,149,289184,150,379157,157,424868,158,106762,161,280010,162,235590,165,217561,172,266631,173,200315,174,388467,175,297857,181,369188,183,293788],[56,121,111372,133,107924],[57,6,289972,114,420957,119,266958,121,111565,127,372077,144,288507,158,110162,175,302496],[58,17,197366,21,245231,74,104124,78,215230,102,213041,121,111565,124,384598,144,287805,158,173028,165,217445,172,269648,175,297908],[59,34,391437,61,114991,65,363441,74,105537,78,215231,89,106995,90,344794,102,212810,124,384583,136,365953,158,196543,175,297902],[60,14,123957,18,107350,21,252949,36,303740,49,382413,56,346880,74,102675,77,301840,78,215467,82,342336,84,212308,94,291699,100,198904,102,215489,107,113320,114,420022,124,384639,127,372776,136,365686,150,384747,158,106330,173,201096,175,299916,176,116916],[61,1,191420,13,221449,17,197118,18,113400,32,318997,34,194687,42,291861,48,236805,57,302135,61,206823,62,179418,69,422282,77,281930,78,363173,81,122369,83,250206,100,199717,102,212500,113,130376,114,419958,116,302518,118,381733,124,382618,133,108810,135,376961,144,103912,145,106400,147,374707,149,289246,154,304637,157,424849,158,353670,161,279995,172,269968,173,113543,175,297846,181,369686],[62,133,108810],[63,1,191363,14,127000,24,288037,28,189890,34,365376,48,236230,56,441217,57,302789,74,105340,78,284879,96,240556,102,213420,104,384238,124,385272,135,377004,137,298932,148,118495,158,107956,161,110547,170,366328,173,113261,175,303255],[65,2,337650,4,441098,6,290343,32,318924,34,372242,42,294133,47,375872,49,382321,61,427163,77,281660,78,294667,83,353649,89,282739,90,345104,104,408130,115,357049,119,267398,121,432547,124,382456,127,372515,128,317930,132,391156,133,430633,136,365533,137,298941,149,289151,150,379391,157,424757,158,272882,161,279952,162,287535,172,266559,173,281659,174,390786,175,297783,181,369141,183,293771],[66,36,304038,42,292535,45,270412,50,366698,56,338296,57,302887,69,423947,78,285653,114,421210,115,356053,116,302761,121,431688,124,383151,128,318191,130,431526,133,430633,136,364820,144,385684,157,429858,158,273730,172,265210,175,298518],[67,3,354577,4,440718,6,290151,10,374475,11,365914,31,369952,32,319012,34,373125,36,302051,42,291840,43,270912,45,269845,47,375384,49,382019,50,365552,57,304793,60,374837,61,427278,69,422650,77,281802,78,294678,82,342332,83,353757,86,428257,94,291103,104,384609,106,293733,109,432623,114,419903,115,355827,116,299864,118,380748,119,267188,121,284856,124,382605,127,372765,128,318014,130,430679,132,390903,134,341283,136,365614,137,297904,144,289098,147,374717,149,289252,157,424893,158,273017,161,279922,170,364913,171,280210,172,268962,175,297805,181,369750],[68,6,290183,10,374344,19,368988,27,299118,32,319041,34,271673,36,303683,42,291901,43,270914,44,284640,45,269651,47,376863,49,382406,50,365594,56,337726,57,302995,62,436784,71,299742,77,282733,78,285959,82,342332,83,353823,86,329345,90,344859,91,421617,94,291406,104,383983,114,419928,115,355154,116,299868,121,283727,124,382755,127,292292,128,318045,130,430698,132,391737,134,341392,136,364394,137,297941,144,289066,147,374832,149,289418,150,379152,154,304658,157,424484,158,273073,160,430905,161,283747,170,364972,171,280265,172,264794,173,282819,174,388508,175,299623],[69,10,296402,11,365566,34,365234,36,302688,43,270772,47,375632,49,381976,57,302134,61,426392,65,363478,78,294665,83,354920,89,282426,94,291007,106,293677,115,355666,116,300288,118,380568,121,432498,123,302226,124,382445,130,429727,132,390515,136,365522,137,299237,147,374645,149,290945,157,424893,158,273019,161,279942,162,287388,170,365279,171,280084,172,267250,173,282165,174,390615,175,297916,181,369369],[70,10,374721,11,366069,36,303579,42,292886,45,270377,57,304625,77,284531,86,429861,89,284239,115,356053,116,302978,121,432498,124,384000,128,318581,130,431496,136,365520,144,385719,157,430156,158,276314,172,268248,175,300409],[71,118,380826,124,386975,158,283967],[73,45,271543,89,282703],[74,6,290345,34,380603,61,427400,77,282057,104,408098,115,357053,124,384549,127,372524,132,391057,133,430683,136,365533,137,298936,144,383554,149,289152,158,272891,161,279950,172,267329,175,297791,181,369145],[75,133,430683,149,292107,158,349944],[76,132,391517]]] \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7c69165..0000000 --- a/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.tmp-edge-profile/ -__pycache__/ -*.pyc - -# Local browser smoke/probe artifacts. -*probe*.html -*probe*.png -*smoke*.html -*smoke*.png diff --git a/.htaccess b/.htaccess index 1a8ccd5..fedc16b 100644 --- a/.htaccess +++ b/.htaccess @@ -1,3 +1,5 @@ +AddType application/manifest+json .webmanifest + ExpiresActive On ExpiresByType text/html "access plus 0 seconds" @@ -21,15 +23,22 @@ Header set Pragma "no-cache" Header set Expires "0" + + Header set Cache-Control "public, max-age=2592000, immutable" + Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" - - Header set Cache-Control "public, max-age=2592000, immutable" - AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json text/plain + + +# Never serve hidden server-data paths. + + RewriteEngine On + RewriteRule (^|/)\. - [R=404,L] + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/ACHIEVEMENT_AUDIT.md b/ACHIEVEMENT_AUDIT.md deleted file mode 100644 index 5f3a086..0000000 --- a/ACHIEVEMENT_AUDIT.md +++ /dev/null @@ -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が再構築されると、1~6体までの途中人数は失われる。 - -候補: - -- **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系統で、すべて修正済み**。上記Q1~Q7は仕様回答待ちのため、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. diff --git a/AI_MAP.md b/AI_MAP.md deleted file mode 100644 index db0401f..0000000 --- a/AI_MAP.md +++ /dev/null @@ -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. diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt deleted file mode 100644 index 06df610..0000000 --- a/BUILD_NOTE.txt +++ /dev/null @@ -1 +0,0 @@ -Current build metadata is defined in js/version.js and app_manifest.json. diff --git a/FILES.json b/FILES.json deleted file mode 100644 index 08ffb02..0000000 --- a/FILES.json +++ /dev/null @@ -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" - } -} diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md deleted file mode 100644 index 0b440d3..0000000 --- a/GAME_FEATURES.md +++ /dev/null @@ -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 0–100%, 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`. diff --git a/README.md b/README.md index c9ab1b4..c719566 100644 --- a/README.md +++ b/README.md @@ -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公開ディレクトリ外へ設定してください。 diff --git a/achievement_api.php b/achievement_api.php index cedb961..22fbb63 100644 --- a/achievement_api.php +++ b/achievement_api.php @@ -4,91 +4,63 @@ declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('X-Content-Type-Options: nosniff'); +header('Referrer-Policy: no-referrer'); +header('X-Frame-Options: DENY'); + +function load_achievement_catalog(): array { + $path = __DIR__ . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . 'achievement_catalog.js'; + $raw = @file_get_contents($path); + if (!is_string($raw)) throw new RuntimeException('achievement_catalog_unavailable'); + $prefix = 'window.TARINAI_ACHIEVEMENT_CATALOG = '; + $trimmed = trim($raw); + if (!str_starts_with($trimmed, $prefix) || !str_ends_with($trimmed, ';')) { + throw new RuntimeException('achievement_catalog_invalid'); + } + $json = substr($trimmed, strlen($prefix), -1); + try { $catalog = json_decode($json, true, 512, JSON_THROW_ON_ERROR); } + catch (JsonException) { throw new RuntimeException('achievement_catalog_invalid'); } + if (!is_array($catalog) || !is_array($catalog['definitions'] ?? null)) { + throw new RuntimeException('achievement_catalog_invalid'); + } + return $catalog; +} + +$achievementCatalog = load_achievement_catalog(); +$achievementDefinitions = []; +$achievementIds = []; +$progressRules = []; +foreach ($achievementCatalog['definitions'] as $definition) { + if (!is_array($definition)) continue; + $id = (string)($definition['id'] ?? ''); + if ($id === '' || isset($achievementDefinitions[$id])) throw new RuntimeException('achievement_catalog_invalid'); + $achievementDefinitions[$id] = $definition; + $achievementIds[] = $id; + if (is_array($definition['progress'] ?? null)) $progressRules[$id] = $definition['progress']; +} +define('ACHIEVEMENT_IDS', $achievementIds); +define('ACHIEVEMENT_DEFINITIONS', $achievementDefinitions); +define('PROGRESS_RULES', $progressRules); +define('COMPLETIONIST_ID', (string)($achievementCatalog['completionistId'] ?? 'true_tarinai_observer')); +define('CATALOG_REVISION', (string)($achievementCatalog['revision'] ?? 'unknown')); +define('ADDITIVE_PROGRESS_CAPS', is_array($achievementCatalog['additiveProgressCaps'] ?? null) ? $achievementCatalog['additiveProgressCaps'] : []); +define('MEDICINE_LEDGER_TYPES', is_array($achievementCatalog['medicineLedgerTypes'] ?? null) ? array_values($achievementCatalog['medicineLedgerTypes']) : []); +define('LINK_PROGRESS_TYPES', is_array($achievementCatalog['linkProgressTypes'] ?? null) ? array_values($achievementCatalog['linkProgressTypes']) : []); +define('MAD_SCIENTIST_TARGET', max(1, (int)($progressRules['mad_scientist']['target'] ?? 10))); -const ACHIEVEMENT_IDS = [ - 'first_birth', - 'natural_zunchi_slave', - 'natural_tarinai_king', - 'self_zunchi_death', - 'zunchi_collision_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', -]; -const DATA_DIR_NAME = '.achievement_data'; -const DATA_FILE_NAME = 'state.json'; -const LOCK_FILE_NAME = 'state.lock'; -const STATE_SCHEMA_VERSION = 3; const MAX_REQUEST_BYTES = 131072; +const RATE_WINDOW_SECONDS = 60; +const RATE_READ_LIMIT = 240; +const RATE_SESSION_IP_LIMIT = 120; +const RATE_WRITE_IP_LIMIT = 600; +const RATE_PLAYER_WRITE_LIMIT = 120; +const QUALIFY_MIN_ACTIVITY = 2; +const QUALIFY_MIN_AGE_SECONDS = 30; +const SESSION_COOKIE_NAME = 'tarinai_achievement_session_v2'; +const SESSION_COOKIE_MAX_AGE = 31536000; +const IDENTITY_ISSUE_WINDOW_SECONDS = 86400; +const IDENTITY_ISSUE_LIMIT_PER_IP = 512; +const SYNC_PROTOCOL_VERSION = 5; + function respond(array $payload, int $status = 200): never { http_response_code($status); @@ -97,266 +69,962 @@ function respond(array $payload, int $status = 200): never { } function valid_player_id(string $value): bool { - return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) === 1; + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1; } -function empty_state(): array { - return ['players' => [], 'unlocks' => []]; +function valid_player_secret(string $value): bool { + return preg_match('/^[0-9a-f]{64}$/i', $value) === 1; } -function compact_player_id(string $value): string { - $hex = str_replace('-', '', strtolower($value)); - if (strlen($hex) !== 32 || !ctype_xdigit($hex)) return ''; - $binary = hex2bin($hex); - if ($binary === false) return ''; - return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); +function client_ip(): string { + return substr((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'), 0, 128); } -function expand_player_id(string $value): string { - if (strlen($value) !== 22 || preg_match('/^[A-Za-z0-9_-]{22}$/', $value) !== 1) return ''; - $binary = base64_decode(strtr($value, '-_', '+/') . '==', true); - if ($binary === false || strlen($binary) !== 16) return ''; - $hex = bin2hex($binary); - return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); +function directory_has_achievement_data(string $path): bool { + return is_file($path . DIRECTORY_SEPARATOR . 'state.json') + || is_file($path . DIRECTORY_SEPARATOR . 'summary.json') + || is_file($path . DIRECTORY_SEPARATOR . 'session-key.json') + || is_dir($path . DIRECTORY_SEPARATOR . 'players'); } -function detect_state_format(mixed $value): string { - if (!is_array($value)) return 'invalid'; - if ($value === []) return 'empty'; - if (array_is_list($value)) { - return (($value[0] ?? null) === STATE_SCHEMA_VERSION - && isset($value[1], $value[2], $value[3], $value[4]) - && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) - ? 'v3' : 'invalid'; +function copy_directory_tree(string $source, string $target): void { + ensure_directory($target); + $items = @scandir($source); + if (!is_array($items)) throw new RuntimeException('storage_unavailable'); + foreach ($items as $name) { + if ($name === '.' || $name === '..') continue; + $from = $source . DIRECTORY_SEPARATOR . $name; + $to = $target . DIRECTORY_SEPARATOR . $name; + if (is_dir($from)) copy_directory_tree($from, $to); + elseif (is_file($from) && !is_file($to) && !@copy($from, $to)) throw new RuntimeException('storage_write_failed'); } - if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { - return 'v2'; - } - if (array_key_exists('players', $value) || array_key_exists('unlocks', $value)) return 'verbose'; - return 'invalid'; } -function normalize_state(mixed $value): array { - if (!is_array($value)) return empty_state(); - $players = []; - $unlocks = []; - - // v3 positional form: [version, baseTime, versions, players, unlocks]. - if (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4]) - && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) { - $baseTime = max(1, (int)$value[1]); - $versions = array_map(static fn($entry): string => substr((string)$entry, 0, 32), $value[2]); - $playerIds = []; - foreach ($value[3] as $row) { - if (!is_array($row) || !isset($row[0]) || !is_string($row[0])) continue; - $id = expand_player_id($row[0]); - if (!valid_player_id($id)) continue; - $firstSeen = $baseTime + max(0, (int)($row[1] ?? 0)); - $lastSeen = $firstSeen + max(0, (int)($row[2] ?? 0)); - $versionIndex = max(0, (int)($row[3] ?? 0)); - $playerIds[] = $id; - $players[$id] = [ - 'firstSeen' => max(1, $firstSeen), - 'lastSeen' => max(1, $lastSeen), - 'gameVersion' => $versions[$versionIndex] ?? '', - ]; - } - foreach ($value[4] as $row) { - if (!is_array($row) || count($row) < 3) continue; - $achievementIndex = (int)$row[0]; - $achievementId = ACHIEVEMENT_IDS[$achievementIndex] ?? ''; - if ($achievementId === '') continue; - $records = []; - $count = count($row); - for ($i = 1; $i + 1 < $count; $i += 2) { - $playerIndex = (int)$row[$i]; - if (!isset($playerIds[$playerIndex])) continue; - $records[$playerIds[$playerIndex]] = $baseTime + max(0, (int)$row[$i + 1]); - } - if ($records) $unlocks[$achievementId] = $records; - } - // v2 is an on-read migration source and is always rewritten as v3. - } elseif (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { - $playerIds = []; - foreach ($value['p'] as $row) { - if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue; - $id = strtolower($row[0]); - $playerIds[] = $id; - $players[$id] = [ - 'firstSeen' => max(1, (int)($row[1] ?? 1)), - 'lastSeen' => max(1, (int)($row[2] ?? ($row[1] ?? 1))), - 'gameVersion' => substr((string)($row[3] ?? ''), 0, 32), - ]; - } - foreach ($value['u'] as $achievementId => $flat) { - if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($flat)) continue; - $records = []; - $count = count($flat); - for ($i = 0; $i + 1 < $count; $i += 2) { - $playerIndex = (int)$flat[$i]; - if (!isset($playerIds[$playerIndex])) continue; - $records[$playerIds[$playerIndex]] = max(1, (int)$flat[$i + 1]); - } - if ($records) $unlocks[$achievementId] = $records; - } - } else { - $rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : []; - foreach ($rawPlayers as $id => $player) { - if (!is_string($id) || !valid_player_id($id) || !is_array($player)) continue; - $players[strtolower($id)] = [ - 'firstSeen' => max(1, (int)($player['firstSeen'] ?? 1)), - 'lastSeen' => max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))), - 'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32), - ]; - } - $rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; - foreach ($rawUnlocks as $achievementId => $records) { - if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue; - foreach ($records as $id => $timestamp) { - $normalizedId = strtolower((string)$id); - if (!isset($players[$normalizedId])) continue; - $unlocks[$achievementId][$normalizedId] = max(1, (int)$timestamp); - } - } - } - - - return ['players' => $players, 'unlocks' => $unlocks]; +function legacy_state_snapshot(string $path): ?array { + if (!is_file($path)) return null; + $bytes = @file_get_contents($path); + if (!is_string($bytes) || trim($bytes) === '') throw new RuntimeException('legacy_state_unreadable'); + return [ + 'path' => $path, + 'bytes' => $bytes, + 'hash' => hash('sha256', $bytes), + 'size' => strlen($bytes), + 'mtime' => max(0, (int)@filemtime($path)), + ]; } -function compact_state(array $state): array { - $playerIds = array_keys($state['players']); - sort($playerIds, SORT_STRING); - $playerIndex = []; - $timestamps = []; - $versionCounts = []; - foreach ($playerIds as $id) { - $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; - $timestamps[] = max(1, (int)($player['firstSeen'] ?? 1)); - $timestamps[] = max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))); - $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); - $versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1; +function remember_legacy_state_source(string $target, array $source): void { + atomic_write_json($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', [ + 'schema' => 1, + 'sourcePath' => (string)$source['path'], + 'sourceHash' => (string)$source['hash'], + 'sourceSize' => (int)$source['size'], + 'sourceMtime' => (int)$source['mtime'], + 'copiedAt' => time(), + ]); +} + +function copy_legacy_state_snapshot(string $target, array $source): void { + atomic_write_bytes($target . DIRECTORY_SEPARATOR . 'state.json', (string)$source['bytes']); + remember_legacy_state_source($target, $source); +} + +function migrate_legacy_data_root(string $target, array $legacyRoots): void { + $targetCurrent = is_file($target . DIRECTORY_SEPARATOR . 'summary.json') + || is_file($target . DIRECTORY_SEPARATOR . 'session-key.json') + || is_dir($target . DIRECTORY_SEPARATOR . 'players'); + $targetStatePath = $target . DIRECTORY_SEPARATOR . 'state.json'; + $targetState = legacy_state_snapshot($targetStatePath); + + $candidates = []; + $stateCandidates = []; + foreach ($legacyRoots as $legacy) { + $path = rtrim((string)$legacy, DIRECTORY_SEPARATOR); + if ($path === '' || $path === $target || !is_dir($path) || !directory_has_achievement_data($path)) continue; + $directoryMtime = max( + (int)@filemtime($path), + (int)@filemtime($path . DIRECTORY_SEPARATOR . 'state.json'), + (int)@filemtime($path . DIRECTORY_SEPARATOR . 'summary.json') + ); + $candidates[$path] = $directoryMtime; + $snapshot = legacy_state_snapshot($path . DIRECTORY_SEPARATOR . 'state.json'); + if ($snapshot !== null) $stateCandidates[$path] = $snapshot; } - foreach ($state['unlocks'] as $achievementId => $records) { - if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue; - foreach ($records as $timestamp) $timestamps[] = max(1, (int)$timestamp); - } - $baseTime = $timestamps ? min($timestamps) : 1; - uksort($versionCounts, static function (string $a, string $b) use ($versionCounts): int { - $countOrder = ($versionCounts[$b] ?? 0) <=> ($versionCounts[$a] ?? 0); - return $countOrder !== 0 ? $countOrder : strcmp($a, $b); + arsort($candidates, SORT_NUMERIC); + uasort($stateCandidates, static function (array $a, array $b): int { + $mtime = ((int)$b['mtime']) <=> ((int)$a['mtime']); + if ($mtime !== 0) return $mtime; + return ((int)$b['size']) <=> ((int)$a['size']); }); - $versions = array_keys($versionCounts); - if (!$versions) $versions = ['']; - $versionIndex = array_flip($versions); - $players = []; - foreach ($playerIds as $index => $id) { - $playerIndex[$id] = $index; - $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; - $firstSeen = max(1, (int)($player['firstSeen'] ?? 1)); - $lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen)); - $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); - $row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen]; - $indexValue = (int)($versionIndex[$version] ?? 0); - if ($indexValue !== 0) $row[] = $indexValue; - $players[] = $row; + // When a compact source was copied into a private current root, keep + // comparing the original source on later requests. This captures final + // writes made by the legacy server during cut-over instead of freezing the + // first copied snapshot forever. + if ($targetState !== null && $stateCandidates) { + $sourceMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', []); + $migrationMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json', []); + $preferredPath = (string)($sourceMarker['sourcePath'] ?? ''); + $source = $preferredPath !== '' && isset($stateCandidates[dirname($preferredPath)]) + ? $stateCandidates[dirname($preferredPath)] + : reset($stateCandidates); + if (is_array($source) && !hash_equals((string)$targetState['hash'], (string)$source['hash'])) { + $trackedSource = $preferredPath !== '' && (string)$source['path'] === $preferredPath; + $targetIsLastImportedSnapshot = hash_equals( + strtolower((string)($migrationMarker['sourceHash'] ?? '')), + strtolower((string)$targetState['hash']) + ); + $sourceIsNotOlder = (int)$source['mtime'] >= (int)$targetState['mtime']; + if ($trackedSource || $targetIsLastImportedSnapshot || $sourceIsNotOlder) { + copy_legacy_state_snapshot($target, $source); + $targetState = $source; + } + } + return; } - $unlocks = []; - foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) { - $records = $state['unlocks'][$achievementId] ?? null; - if (!is_array($records) || !$records) continue; - $pairs = []; - foreach ($records as $id => $timestamp) { - if (!isset($playerIndex[$id])) continue; - $pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime]; - } - usort($pairs, static fn(array $a, array $b): int => $a[0] <=> $b[0]); - if (!$pairs) continue; - $row = [$achievementIndex]; - foreach ($pairs as [$indexValue, $timestampDelta]) { - $row[] = $indexValue; - $row[] = max(0, $timestampDelta); - } - $unlocks[] = $row; + if (!$candidates) return; + + // A prior current build may already have created an empty summary/player + // tree. Copy the newest compact source into it so the importer can merge it + // without discarding current records. + if ($targetCurrent) { + $source = $stateCandidates ? reset($stateCandidates) : null; + if (is_array($source)) copy_legacy_state_snapshot($target, $source); + return; } - return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks]; + + $sourcePath = (string)array_key_first($candidates); + ensure_directory(dirname($target)); + if (isset($stateCandidates[$sourcePath])) { + copy_directory_tree($sourcePath, $target); + remember_legacy_state_source($target, $stateCandidates[$sourcePath]); + return; + } + if (!file_exists($target) && @rename($sourcePath, $target)) { + // A separate compact source may coexist with a current-format legacy + // directory. Merge the compact snapshot as well. + $stateSource = $stateCandidates ? reset($stateCandidates) : null; + if (is_array($stateSource)) copy_legacy_state_snapshot($target, $stateSource); + return; + } + copy_directory_tree($sourcePath, $target); + $stateSource = $stateCandidates ? reset($stateCandidates) : null; + if (is_array($stateSource) && !is_file($targetStatePath)) copy_legacy_state_snapshot($target, $stateSource); } -function encode_compact_state(array $state): string { +function data_root(): string { + $configured = trim((string)(getenv('TARINAI_ACHIEVEMENT_DATA_DIR') ?: '')); + if ($configured !== '') { + $target = rtrim($configured, DIRECTORY_SEPARATOR); + migrate_legacy_data_root($target, [ + __DIR__ . DIRECTORY_SEPARATOR . '.achievement_data', + dirname(__DIR__) . DIRECTORY_SEPARATOR . '.achievement_data', + ]); + return $target; + } + + $documentRoot = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? '')); + $publicRoot = is_string($documentRoot) && $documentRoot !== '' ? $documentRoot : __DIR__; + $installPath = realpath(__DIR__) ?: __DIR__; + $installKey = substr(hash('sha256', $installPath), 0, 16); + $legacyParent = dirname($publicRoot); + $legacyRoots = [ + $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-' . $installKey, + __DIR__ . DIRECTORY_SEPARATOR . '.achievement_data', + $publicRoot . DIRECTORY_SEPARATOR . '.achievement_data', + ]; + $globbed = glob($legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-*', GLOB_ONLYDIR); + if (is_array($globbed)) $legacyRoots = array_merge($legacyRoots, $globbed); + + // Keep local-server data independent of the ZIP extraction directory and + // listening port. This makes upgrades and side-by-side extracted versions + // share one persistent achievement identity and server record. + $xdg = trim((string)(getenv('XDG_DATA_HOME') ?: '')); + $home = trim((string)(getenv('HOME') ?: getenv('USERPROFILE') ?: '')); + if ($xdg !== '') $target = rtrim($xdg, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements'; + elseif ($home !== '') $target = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements'; + else $target = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements'; + try { - return json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); - } catch (JsonException $error) { - throw new RuntimeException('storage_encode_failed', 0, $error); + migrate_legacy_data_root($target, $legacyRoots); + return $target; + } catch (RuntimeException) { + // Hosted environments may not permit writes under HOME. The fallback + // remains outside the document root and is stable across sibling builds. + $fallback = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements'; + migrate_legacy_data_root($fallback, $legacyRoots); + return $fallback; } } -function decode_stored_state(string $contents): array { - if (trim($contents) === '') return [empty_state(), 'empty']; +function ensure_directory(string $path): void { + if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) throw new RuntimeException('storage_unavailable'); +} + + +function protect_data_root(string $path): void { + ensure_directory($path); + $rules = "Options -Indexes\n\n Require all denied\n\n\n Deny from all\n\n"; + $htaccess = $path . DIRECTORY_SEPARATOR . '.htaccess'; + if (!is_file($htaccess)) @file_put_contents($htaccess, $rules, LOCK_EX); + $index = $path . DIRECTORY_SEPARATOR . 'index.html'; + if (!is_file($index)) @file_put_contents($index, '', LOCK_EX); +} + +function read_json_file(string $path, array $fallback): array { + if (!is_file($path)) return $fallback; + $raw = file_get_contents($path); + if (!is_string($raw)) throw new RuntimeException('storage_unavailable'); + if (trim($raw) === '') return $fallback; try { - $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + $value = json_decode($raw, true, 128, JSON_THROW_ON_ERROR); } catch (JsonException $error) { throw new RuntimeException('storage_corrupt', 0, $error); } - $format = detect_state_format($decoded); - if ($format === 'invalid') throw new RuntimeException('storage_corrupt'); - return [normalize_state($decoded), $format]; + return is_array($value) ? $value : $fallback; } -function atomic_write_state(string $dataPath, string $encoded): void { - $dataDir = dirname($dataPath); - $tempPath = tempnam($dataDir, DATA_FILE_NAME . '.tmp.'); - if ($tempPath === false) throw new RuntimeException('storage_unavailable'); - $handle = @fopen($tempPath, 'wb'); - if ($handle === false) { - @unlink($tempPath); - throw new RuntimeException('storage_unavailable'); - } +function atomic_write_json(string $path, array $value): void { + ensure_directory(dirname($path)); + try { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('storage_encode_failed', 0, $error); + } + $temp = tempnam(dirname($path), basename($path) . '.tmp.'); + if ($temp === false) throw new RuntimeException('storage_unavailable'); + $handle = @fopen($temp, 'wb'); + if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); } try { - $length = strlen($encoded); $offset = 0; + $length = strlen($encoded); while ($offset < $length) { $written = fwrite($handle, substr($encoded, $offset)); if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed'); $offset += $written; } if (!fflush($handle)) throw new RuntimeException('storage_write_failed'); - if (function_exists('fsync') && !fsync($handle)) throw new RuntimeException('storage_write_failed'); + if (function_exists('fsync')) @fsync($handle); } catch (Throwable $error) { - fclose($handle); - @unlink($tempPath); - throw $error; + fclose($handle); @unlink($temp); throw $error; } fclose($handle); - @chmod($tempPath, 0664); - if (!@rename($tempPath, $dataPath)) { - @unlink($tempPath); - throw new RuntimeException('storage_write_failed'); - } + @chmod($temp, 0660); + if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); } } -function build_summary(array $state): array { - $total = count($state['players']); - $achievements = []; - foreach (ACHIEVEMENT_IDS as $id) { - $unlocked = isset($state['unlocks'][$id]) && is_array($state['unlocks'][$id]) - ? count($state['unlocks'][$id]) - : 0; - $percentage = $total > 0 ? round(($unlocked / $total) * 100, 1) : 0.0; - $achievements[$id] = [ - 'unlockedPlayers' => $unlocked, - 'percentage' => $percentage, - ]; + +function atomic_write_bytes(string $path, string $bytes): void { + ensure_directory(dirname($path)); + $temp = tempnam(dirname($path), basename($path) . '.tmp.'); + if ($temp === false) throw new RuntimeException('storage_unavailable'); + $handle = @fopen($temp, 'wb'); + if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); } + try { + $offset = 0; + $length = strlen($bytes); + while ($offset < $length) { + $written = fwrite($handle, substr($bytes, $offset)); + if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed'); + $offset += $written; + } + if (!fflush($handle)) throw new RuntimeException('storage_write_failed'); + if (function_exists('fsync')) @fsync($handle); + } catch (Throwable $error) { + fclose($handle); @unlink($temp); throw $error; } + fclose($handle); + @chmod($temp, 0660); + if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); } +} + +function valid_legacy_player_id(string $value): bool { + return preg_match('/^[A-Za-z0-9_-]{22}$/', $value) === 1; +} + +function legacy_player_uuid(string $legacyId): string { + if (!valid_legacy_player_id($legacyId)) throw new RuntimeException('legacy_state_invalid'); + $decoded = base64_decode(strtr($legacyId, '-_', '+/') . '==', true); + if (!is_string($decoded) || strlen($decoded) !== 16) throw new RuntimeException('legacy_state_invalid'); + $bytes = $decoded; + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); +} + +/** + * Imports the compact schema-4 state.json used by the pre-credential server. + * The source remains untouched and is copied byte-for-byte into legacy-backup. + * Player writes are deterministic and idempotent, so a process interruption is + * safely resumed on the next request before the completion marker is written. + */ +function migrate_legacy_compact_state_v4(string $root, string $summaryPath): ?array { + $sourcePath = $root . DIRECTORY_SEPARATOR . 'state.json'; + if (!is_file($sourcePath)) return null; + $markerPath = $root . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json'; + $marker = read_json_file($markerPath, []); + $sourceSize = max(0, (int)@filesize($sourcePath)); + $sourceMtime = max(0, (int)@filemtime($sourcePath)); + $sourceRaw = @file_get_contents($sourcePath); + if (!is_string($sourceRaw) || trim($sourceRaw) === '') throw new RuntimeException('legacy_state_unreadable'); + $sourceHash = hash('sha256', $sourceRaw); + if (($marker['status'] ?? '') === 'complete' + && hash_equals((string)($marker['sourceHash'] ?? ''), $sourceHash)) { + $marker['sourceSize'] = $sourceSize; + $marker['sourceMtime'] = $sourceMtime; + atomic_write_json($markerPath, $marker); + return $marker; + } + // If the legacy server wrote a final update during cut-over, import the + // changed snapshot again. The merge is grow-only and deterministic. + + try { + $data = json_decode($sourceRaw, true, 256, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('legacy_state_invalid', 0, $error); + } + if (!is_array($data) || count($data) !== 6 || ($data[0] ?? null) !== 4) throw new RuntimeException('legacy_state_invalid'); + $baseTimestamp = $data[1] ?? null; + $versions = $data[2] ?? null; + $catalogIds = $data[3] ?? null; + $playerRows = $data[4] ?? null; + $unlockRows = $data[5] ?? null; + if (!is_int($baseTimestamp) || $baseTimestamp < 946684800 || !is_array($versions) || !$versions + || !is_array($catalogIds) || !is_array($playerRows) || !is_array($unlockRows)) { + throw new RuntimeException('legacy_state_invalid'); + } + foreach ($versions as $version) { + if (!is_string($version) || $version === '' || strlen($version) > 32) throw new RuntimeException('legacy_state_invalid'); + } + if (array_values($catalogIds) !== array_values(ACHIEVEMENT_IDS)) throw new RuntimeException('legacy_catalog_mismatch'); + + $perPlayerUnlocks = array_fill(0, count($playerRows), []); + $legacyCounts = array_fill_keys(ACHIEVEMENT_IDS, 0); + $seenAchievements = []; + $unlockAssignments = 0; + foreach ($unlockRows as $unlockRow) { + if (!is_array($unlockRow) || count($unlockRow) < 3 || count($unlockRow) % 2 !== 1) throw new RuntimeException('legacy_state_invalid'); + $achievementIndex = $unlockRow[0] ?? null; + if (!is_int($achievementIndex) || $achievementIndex < 0 || $achievementIndex >= count(ACHIEVEMENT_IDS) + || isset($seenAchievements[$achievementIndex])) throw new RuntimeException('legacy_state_invalid'); + $seenAchievements[$achievementIndex] = true; + $achievementId = ACHIEVEMENT_IDS[$achievementIndex]; + $seenPlayers = []; + for ($offset = 1; $offset < count($unlockRow); $offset += 2) { + $playerIndex = $unlockRow[$offset] ?? null; + $timeOffset = $unlockRow[$offset + 1] ?? null; + if (!is_int($playerIndex) || $playerIndex < 0 || $playerIndex >= count($playerRows) + || isset($seenPlayers[$playerIndex]) || !is_int($timeOffset) || $timeOffset < 0) { + throw new RuntimeException('legacy_state_invalid'); + } + $timestamp = $baseTimestamp + $timeOffset; + if ($timestamp < 946684800) throw new RuntimeException('legacy_state_invalid'); + $seenPlayers[$playerIndex] = true; + $perPlayerUnlocks[$playerIndex][$achievementId] = $timestamp; + $legacyCounts[$achievementId]++; + $unlockAssignments++; + } + } + + $backupPath = $root . DIRECTORY_SEPARATOR . 'legacy-backup' . DIRECTORY_SEPARATOR . 'state-v4-' . $sourceHash . '.json'; + if (!is_file($backupPath)) atomic_write_bytes($backupPath, $sourceRaw); + $backupRaw = @file_get_contents($backupPath); + if (!is_string($backupRaw) || !hash_equals($sourceHash, hash('sha256', $backupRaw))) throw new RuntimeException('legacy_backup_failed'); + + atomic_write_json($markerPath, [ + 'schema' => 1, + 'status' => 'in_progress', + 'sourceSchema' => 4, + 'sourceHash' => $sourceHash, + 'sourceSize' => $sourceSize, + 'sourceMtime' => $sourceMtime, + 'startedAt' => time(), + 'expectedPlayers' => count($playerRows), + 'expectedUnlockAssignments' => $unlockAssignments, + 'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath), + ]); + + $legacyIdsSeen = []; + $migratedPlayerIds = []; + foreach ($playerRows as $index => $row) { + if (!is_array($row) || count($row) < 3 || count($row) > 4) throw new RuntimeException('legacy_state_invalid'); + $legacyId = $row[0] ?? null; + $firstOffset = $row[1] ?? null; + $duration = $row[2] ?? null; + $versionIndex = $row[3] ?? 0; + if (!is_string($legacyId) || !valid_legacy_player_id($legacyId) || isset($legacyIdsSeen[$legacyId]) + || !is_int($firstOffset) || $firstOffset < 0 || !is_int($duration) || $duration < 0 + || !is_int($versionIndex) || $versionIndex < 0 || $versionIndex >= count($versions)) { + throw new RuntimeException('legacy_state_invalid'); + } + $legacyIdsSeen[$legacyId] = true; + $playerId = legacy_player_uuid($legacyId); + if (isset($migratedPlayerIds[$playerId])) throw new RuntimeException('legacy_player_collision'); + $migratedPlayerIds[$playerId] = true; + $claimHash = hash('sha256', $legacyId); + $path = player_path($root, $playerId); + if (is_file($path)) { + $existingRaw = read_json_file($path, []); + $existingClaimHash = strtolower((string)($existingRaw['legacyClaimHash'] ?? '')); + if ($existingClaimHash !== '' && !hash_equals($existingClaimHash, $claimHash)) throw new RuntimeException('legacy_player_collision'); + if ($existingClaimHash === '' && (string)($existingRaw['ownerHash'] ?? '') !== '') throw new RuntimeException('legacy_player_collision'); + $player = normalize_player($existingRaw, $playerId); + } else { + $player = default_player($playerId, '', $baseTimestamp + $firstOffset); + } + $player['schema'] = 5; + $player['legacyClaimHash'] = $claimHash; + $player['legacyClaimedAt'] = max(0, (int)($player['legacyClaimedAt'] ?? 0)); + $player['firstSeen'] = min((int)$player['firstSeen'], $baseTimestamp + $firstOffset); + $player['lastSeen'] = max((int)$player['lastSeen'], $baseTimestamp + $firstOffset + $duration); + $player['activityCount'] = max(2, (int)$player['activityCount']); + $player['qualified'] = true; + if ((string)$player['gameVersion'] === '') $player['gameVersion'] = $versions[$versionIndex]; + // A later explicit reset must remain authoritative if migration is + // resumed after the player has already advanced to a newer generation. + if ((int)$player['generation'] === 0) { + foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) { + if (!isset($player['unlocked'][$achievementId])) $player['unlocked'][$achievementId] = $timestamp; + else $player['unlocked'][$achievementId] = min((int)$player['unlocked'][$achievementId], $timestamp); + } + } + reconcile_completionist($player['unlocked'], $player['lastSeen']); + atomic_write_json($path, $player); + } + + $verifiedAssignments = 0; + foreach ($playerRows as $index => $row) { + $legacyId = (string)$row[0]; + $playerId = legacy_player_uuid($legacyId); + $player = normalize_player(read_json_file(player_path($root, $playerId), []), $playerId); + if (!hash_equals(hash('sha256', $legacyId), (string)$player['legacyClaimHash'])) throw new RuntimeException('legacy_migration_verify_failed'); + if ((int)$player['generation'] === 0) { + foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) { + if (!isset($player['unlocked'][$achievementId]) || (int)$player['unlocked'][$achievementId] > $timestamp) { + throw new RuntimeException('legacy_migration_verify_failed'); + } + $verifiedAssignments++; + } + } + } + if ($verifiedAssignments !== $unlockAssignments) throw new RuntimeException('legacy_migration_verify_failed'); + + $summary = repair_summary_cache($root, $summaryPath); + if ((int)$summary['totalPlayers'] < count($playerRows)) throw new RuntimeException('legacy_migration_verify_failed'); + foreach ($legacyCounts as $achievementId => $count) { + if ((int)($summary['counts'][$achievementId] ?? 0) < $count) throw new RuntimeException('legacy_migration_verify_failed'); + } + $complete = [ + 'schema' => 1, + 'status' => 'complete', + 'sourceSchema' => 4, + 'sourceHash' => $sourceHash, + 'sourceSize' => $sourceSize, + 'sourceMtime' => $sourceMtime, + 'completedAt' => time(), + 'migratedPlayers' => count($playerRows), + 'migratedUnlockAssignments' => $unlockAssignments, + 'summaryPlayers' => (int)$summary['totalPlayers'], + 'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath), + ]; + atomic_write_json($markerPath, $complete); + return $complete; +} + +function with_lock(string $path, int $mode, callable $callback): mixed { + ensure_directory(dirname($path)); + $handle = @fopen($path, 'c'); + if ($handle === false || !flock($handle, $mode)) { + if (is_resource($handle)) fclose($handle); + throw new RuntimeException('storage_locked'); + } + try { return $callback(); } finally { flock($handle, LOCK_UN); fclose($handle); } +} + +function player_path(string $root, string $playerId): string { + $bucket = substr(hash('sha256', $playerId), 0, 2); + return $root . DIRECTORY_SEPARATOR . 'players' . DIRECTORY_SEPARATOR . $bucket . DIRECTORY_SEPARATOR . hash('sha256', $playerId) . '.json'; +} + +function default_summary(): array { return [ - 'ok' => true, - 'totalPlayers' => $total, - 'achievements' => $achievements, + 'schema' => 1, + 'totalPlayers' => 0, + 'counts' => array_fill_keys(ACHIEVEMENT_IDS, 0), ]; } -$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +function normalize_summary(array $summary): array { + $out = default_summary(); + $out['totalPlayers'] = max(0, (int)($summary['totalPlayers'] ?? 0)); + $counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : []; + foreach (ACHIEVEMENT_IDS as $id) $out['counts'][$id] = max(0, (int)($counts[$id] ?? 0)); + return $out; +} + +function default_progress_state(): array { + return [ + 'progress' => [ + 'linkTypes' => [], + 'signalActivated' => false, + 'medicineTypes' => [], + 'dailyPlayStreak' => 0, + 'dailyPlayLastDay' => 0, + 'mysteryDrugTarinaiIds' => [], + ], + 'ledger' => [ + 'base' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), 0), + 'shards' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), []), + ], + ]; +} + +function progress_counter_value(array $progressState, string $key): int { + $cap = (int)(ADDITIVE_PROGRESS_CAPS[$key] ?? 0); + if ($cap <= 0) return 0; + $base = bounded_int($progressState['ledger']['base'][$key] ?? 0, 0, $cap); + $shards = is_array($progressState['ledger']['shards'][$key] ?? null) ? $progressState['ledger']['shards'][$key] : []; + $sum = $base; + foreach ($shards as $value) $sum += bounded_int($value, 0, $cap); + return min($cap, $sum); +} + +function bounded_int(mixed $value, int $minimum, int $maximum): int { + if (!is_int($value) && !is_float($value) && !is_string($value)) return $minimum; + if (!is_numeric($value)) return $minimum; + $numeric = (float)$value; + if (!is_finite($numeric)) return $minimum; + return max($minimum, min($maximum, (int)floor($numeric))); +} + +function unique_allowed_strings(mixed $input, array $allowed): array { + if (!is_array($input)) return []; + $lookup = array_fill_keys($allowed, true); + $out = []; + foreach ($input as $value) { + $text = (string)$value; + if (isset($lookup[$text])) $out[$text] = true; + } + return array_keys($out); +} + +function normalize_progress_state(mixed $input): array { + $out = default_progress_state(); + if (!is_array($input)) return $out; + $progressSource = is_array($input['progress'] ?? null) ? $input['progress'] : []; + $ledgerSource = is_array($input['ledger'] ?? null) ? $input['ledger'] : []; + $baseSource = is_array($ledgerSource['base'] ?? null) ? $ledgerSource['base'] : []; + $shardsSource = is_array($ledgerSource['shards'] ?? null) ? $ledgerSource['shards'] : []; + + foreach (ADDITIVE_PROGRESS_CAPS as $key => $rawCap) { + $cap = max(0, (int)$rawCap); + $base = bounded_int($baseSource[$key] ?? 0, 0, $cap); + $shards = []; + $rawShards = is_array($shardsSource[$key] ?? null) ? $shardsSource[$key] : []; + foreach ($rawShards as $actorId => $rawValue) { + $id = substr((string)$actorId, 0, 160); + if ($id === '' || preg_match('/[\x00-\x1F\x7F]/', $id) === 1) continue; + $value = bounded_int($rawValue, 0, $cap); + if ($value > 0) $shards[$id] = max((int)($shards[$id] ?? 0), $value); + } + $total = min($cap, $base + array_sum($shards)); + // Protocol v4 and older duplicated additive counters in progress. Read + // them as a one-time migration floor, but never emit them again. + $legacyFloor = bounded_int($progressSource[$key] ?? 0, 0, $cap); + if ($legacyFloor > $total) { + $base = min($cap, $base + ($legacyFloor - $total)); + $total = $legacyFloor; + } + if ($total >= $cap) { + $base = $cap; + $shards = []; + } + $out['ledger']['base'][$key] = $base; + $out['ledger']['shards'][$key] = $shards; + } + + $out['progress']['linkTypes'] = unique_allowed_strings($progressSource['linkTypes'] ?? [], LINK_PROGRESS_TYPES); + $out['progress']['signalActivated'] = (bool)($progressSource['signalActivated'] ?? false); + $out['progress']['medicineTypes'] = unique_allowed_strings($progressSource['medicineTypes'] ?? [], MEDICINE_LEDGER_TYPES); + $out['progress']['dailyPlayStreak'] = bounded_int($progressSource['dailyPlayStreak'] ?? 0, 0, 7); + $out['progress']['dailyPlayLastDay'] = bounded_int($progressSource['dailyPlayLastDay'] ?? 0, 0, 1000000); + $ids = []; + foreach ((array)($progressSource['mysteryDrugTarinaiIds'] ?? []) as $rawId) { + $id = substr(trim((string)$rawId), 0, 160); + if ($id !== '' && preg_match('/[\x00-\x1F\x7F]/', $id) !== 1) $ids[$id] = true; + if (count($ids) >= MAD_SCIENTIST_TARGET) break; + } + $out['progress']['mysteryDrugTarinaiIds'] = array_keys($ids); + return $out; +} + +function merge_progress_state(mixed $targetInput, mixed $sourceInput): array { + $target = normalize_progress_state($targetInput); + $source = normalize_progress_state($sourceInput); + foreach (ADDITIVE_PROGRESS_CAPS as $key => $cap) { + $target['ledger']['base'][$key] = max((int)$target['ledger']['base'][$key], (int)$source['ledger']['base'][$key]); + foreach ($source['ledger']['shards'][$key] as $actorId => $value) { + $target['ledger']['shards'][$key][$actorId] = max((int)($target['ledger']['shards'][$key][$actorId] ?? 0), (int)$value); + } + } + $targetDay = (int)$target['progress']['dailyPlayLastDay']; + $sourceDay = (int)$source['progress']['dailyPlayLastDay']; + if ($sourceDay > $targetDay) { + $target['progress']['dailyPlayLastDay'] = $sourceDay; + $target['progress']['dailyPlayStreak'] = (int)$source['progress']['dailyPlayStreak']; + } elseif ($sourceDay === $targetDay) { + $target['progress']['dailyPlayStreak'] = max((int)$target['progress']['dailyPlayStreak'], (int)$source['progress']['dailyPlayStreak']); + } + $target['progress']['signalActivated'] = (bool)$target['progress']['signalActivated'] || (bool)$source['progress']['signalActivated']; + $target['progress']['linkTypes'] = array_values(array_unique(array_merge($target['progress']['linkTypes'], $source['progress']['linkTypes']))); + $target['progress']['medicineTypes'] = array_values(array_unique(array_merge($target['progress']['medicineTypes'], $source['progress']['medicineTypes']))); + $target['progress']['mysteryDrugTarinaiIds'] = array_slice(array_values(array_unique(array_merge( + $target['progress']['mysteryDrugTarinaiIds'], + $source['progress']['mysteryDrugTarinaiIds'] + ))), 0, MAD_SCIENTIST_TARGET); + return normalize_progress_state($target); +} + +function progress_unlock_satisfied(string $id, array $progressState): ?bool { + $rule = PROGRESS_RULES[$id] ?? null; + if (!is_array($rule)) return null; + $type = (string)($rule['type'] ?? ''); + $key = (string)($rule['key'] ?? ''); + $target = max(0, (int)($rule['target'] ?? 0)); + $progress = $progressState['progress']; + if ($type === 'counter') return progress_counter_value($progressState, $key) >= $target; + if ($type === 'value') return bounded_int($progress[$key] ?? 0, 0, max($target, 1000000)) >= $target; + if ($type === 'set') { + $required = (string)($rule['required'] ?? ''); + $requiredValues = $required === 'medicineLedgerTypes' ? MEDICINE_LEDGER_TYPES : []; + return count(array_intersect($requiredValues, (array)($progress[$key] ?? []))) === count($requiredValues); + } + if ($type === 'unique') return count((array)($progress[$key] ?? [])) >= $target; + if ($type === 'mechanized') { + return (bool)($progress['signalActivated'] ?? false) + && count(array_intersect(LINK_PROGRESS_TYPES, (array)($progress['linkTypes'] ?? []))) === count(LINK_PROGRESS_TYPES); + } + return null; +} + +function reconcile_progress_unlocks(array &$unlocked, array $progressState, int $now): void { + foreach (ACHIEVEMENT_IDS as $id) { + if ($id === COMPLETIONIST_ID || isset($unlocked[$id])) continue; + if (progress_unlock_satisfied($id, $progressState) === true) $unlocked[$id] = $now; + } +} + +function default_player(string $playerId, string $ownerHash, int $now): array { + return [ + 'schema' => 5, + 'playerId' => $playerId, + 'ownerHash' => $ownerHash, + 'firstSeen' => $now, + 'lastSeen' => $now, + 'activityCount' => 0, + 'qualified' => false, + 'gameVersion' => '', + 'generation' => 0, + 'unlocked' => [], + 'progressState' => default_progress_state(), + ]; +} + +function normalize_player(array $player, string $playerId): array { + $now = time(); + $unlocked = []; + foreach ((array)($player['unlocked'] ?? []) as $id => $timestamp) { + if (is_string($id) && in_array($id, ACHIEVEMENT_IDS, true)) $unlocked[$id] = max(1, (int)$timestamp); + } + return [ + 'schema' => 5, + 'playerId' => $playerId, + 'ownerHash' => (string)($player['ownerHash'] ?? ''), + 'firstSeen' => max(1, (int)($player['firstSeen'] ?? $now)), + 'lastSeen' => max(1, (int)($player['lastSeen'] ?? $now)), + 'activityCount' => max(0, (int)($player['activityCount'] ?? 0)), + 'qualified' => (bool)($player['qualified'] ?? false), + 'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32), + 'generation' => max(0, (int)($player['generation'] ?? 0)), + 'unlocked' => $unlocked, + 'progressState' => normalize_progress_state($player['progressState'] ?? null), + 'legacyClaimHash' => preg_match('/^[0-9a-f]{64}$/', strtolower((string)($player['legacyClaimHash'] ?? ''))) === 1 + ? strtolower((string)$player['legacyClaimHash']) : '', + 'legacyClaimedAt' => max(0, (int)($player['legacyClaimedAt'] ?? 0)), + ]; +} + +/** + * The summary is a derived cache, never the source of truth. Rebuilding it from + * player records makes a corrupt/missing summary self-healing and repairs a + * process interruption between the player and summary renames. + */ +function rebuild_summary_from_players(string $root): array { + $summary = default_summary(); + $playersRoot = $root . DIRECTORY_SEPARATOR . 'players'; + if (!is_dir($playersRoot)) return $summary; + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($playersRoot, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (!$file->isFile() || strtolower($file->getExtension()) !== 'json') continue; + try { + $raw = read_json_file($file->getPathname(), []); + $playerId = strtolower((string)($raw['playerId'] ?? '')); + if (!valid_player_id($playerId)) continue; + $player = normalize_player($raw, $playerId); + } catch (Throwable) { + // One damaged player record must not take down every player. + continue; + } + if (!(bool)$player['qualified']) continue; + $summary['totalPlayers']++; + foreach (array_keys($player['unlocked']) as $id) { + if (isset($summary['counts'][$id])) $summary['counts'][$id]++; + } + } + } catch (UnexpectedValueException) { + throw new RuntimeException('storage_unavailable'); + } + return normalize_summary($summary); +} + +function repair_summary_cache(string $root, string $summaryPath): array { + $summary = rebuild_summary_from_players($root); + atomic_write_json($summaryPath, $summary); + return $summary; +} + +function verify_owner(array &$player, string $secret): void { + $submitted = hash('sha256', strtolower($secret)); + $stored = (string)($player['ownerHash'] ?? ''); + if ($stored === '' || !hash_equals($stored, $submitted)) respond(['ok' => false, 'error' => 'invalid_owner'], 403); +} + +function reconcile_completionist(array &$unlocked, int $now): void { + $latestPrerequisite = 0; + foreach (ACHIEVEMENT_IDS as $id) { + if ($id === COMPLETIONIST_ID) continue; + if (!isset($unlocked[$id])) { unset($unlocked[COMPLETIONIST_ID]); return; } + $latestPrerequisite = max($latestPrerequisite, max(1, (int)$unlocked[$id])); + } + $completionTime = max(1, $latestPrerequisite ?: $now); + if (!isset($unlocked[COMPLETIONIST_ID])) $unlocked[COMPLETIONIST_ID] = $completionTime; + else $unlocked[COMPLETIONIST_ID] = max((int)$unlocked[COMPLETIONIST_ID], $completionTime); +} + +function normalize_client_unlock_time(mixed $value, int $now): int { + if (!is_int($value) && !is_float($value) && !is_string($value)) return $now; + if (!is_numeric($value)) return $now; + $numeric = (float)$value; + if (!is_finite($numeric) || $numeric <= 0) return $now; + // Browser timestamps are milliseconds; legacy/server timestamps are seconds. + if ($numeric >= 100000000000.0) $numeric /= 1000.0; + $seconds = (int)floor($numeric); + // Reject implausible/future values, but preserve valid offline unlock times. + if ($seconds < 946684800 || $seconds > $now + 300) return $now; + return $seconds; +} + +function apply_summary_delta(array &$summary, array $before, array $after): void { + foreach (ACHIEVEMENT_IDS as $id) { + $had = isset($before[$id]); + $has = isset($after[$id]); + if ($had === $has) continue; + $summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + ($has ? 1 : -1)); + } +} + +function maybe_qualify(array &$player, array &$summary, int $now): bool { + if ($player['qualified']) return false; + if ((int)$player['activityCount'] < QUALIFY_MIN_ACTIVITY) return false; + if ($now - (int)$player['firstSeen'] < QUALIFY_MIN_AGE_SECONDS) return false; + $player['qualified'] = true; + $summary['totalPlayers'] = max(0, (int)$summary['totalPlayers'] + 1); + foreach (array_keys($player['unlocked']) as $id) $summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + 1); + return true; +} + +function public_summary(array $summary, ?array $player = null): array { + $total = max(0, (int)$summary['totalPlayers']); + $achievements = []; + foreach (ACHIEVEMENT_IDS as $id) { + $count = max(0, min($total, (int)($summary['counts'][$id] ?? 0))); + $achievements[$id] = [ + 'unlockedPlayers' => $count, + 'percentage' => $total > 0 ? round($count * 100 / $total, 1) : 0.0, + ]; + } + $out = ['ok' => true, 'totalPlayers' => $total, 'achievements' => $achievements, 'statisticsTrust' => 'client-reported']; + if ($player !== null) { + $out['qualified'] = (bool)($player['qualified'] ?? false); + $out['playerGeneration'] = max(0, (int)($player['generation'] ?? 0)); + // Return the authenticated player's grow-only set. The client uploads + // its offline snapshot first, then merges this atomic union response. + $out['playerUnlocked'] = (object)(is_array($player['unlocked'] ?? null) ? $player['unlocked'] : []); + $out['playerProgress'] = normalize_progress_state($player['progressState'] ?? null); + } + return $out; +} + +function enforce_rate_limit(string $root, string $scope, int $limit): void { + $now = time(); + $hash = hash('sha256', $scope); + $path = $root . DIRECTORY_SEPARATOR . 'rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json'; + ensure_directory(dirname($path)); + $lockPath = $path . '.lock'; + with_lock($lockPath, LOCK_EX, function () use ($path, $now, $limit): void { + $state = read_json_file($path, ['window' => $now, 'count' => 0]); + $window = (int)($state['window'] ?? $now); + $count = (int)($state['count'] ?? 0); + if ($now - $window >= RATE_WINDOW_SECONDS) { $window = $now; $count = 0; } + $count++; + if ($count > $limit) respond(['ok' => false, 'error' => 'rate_limited', 'retryAfter' => max(1, RATE_WINDOW_SECONDS - ($now - $window))], 429); + atomic_write_json($path, ['window' => $window, 'count' => $count]); + }); +} + +function claim_identity_issue_slot(string $root, string $ip): void { + $now = time(); + $hash = hash('sha256', 'identity:' . $ip); + $path = $root . DIRECTORY_SEPARATOR . 'identity-rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json'; + with_lock($path . '.lock', LOCK_EX, function () use ($path, $now): void { + $state = read_json_file($path, ['window' => $now, 'count' => 0]); + $window = max(1, (int)($state['window'] ?? $now)); + $count = max(0, (int)($state['count'] ?? 0)); + if ($now - $window >= IDENTITY_ISSUE_WINDOW_SECONDS) { $window = $now; $count = 0; } + if ($count >= IDENTITY_ISSUE_LIMIT_PER_IP) { + respond([ + 'ok' => false, + 'error' => 'identity_creation_limited', + 'retryAfter' => max(1, IDENTITY_ISSUE_WINDOW_SECONDS - ($now - $window)), + ], 429); + } + atomic_write_json($path, ['window' => $window, 'count' => $count + 1]); + }); +} + +function random_uuid_v4(): string { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); +} + +function session_signing_key(string $root): string { + $path = $root . DIRECTORY_SEPARATOR . 'session-key.json'; + return with_lock($path . '.lock', LOCK_EX, function () use ($path): string { + $state = read_json_file($path, []); + $hex = strtolower((string)($state['key'] ?? '')); + if (preg_match('/^[0-9a-f]{64}$/', $hex) !== 1) { + $hex = bin2hex(random_bytes(32)); + atomic_write_json($path, ['key' => $hex]); + } + $key = hex2bin($hex); + if (!is_string($key) || strlen($key) !== 32) throw new RuntimeException('storage_corrupt'); + return $key; + }); +} + +function make_session_cookie(string $root, string $playerId, string $secret): string { + $payload = strtolower($playerId) . '.' . strtolower($secret); + return $payload . '.' . hash_hmac('sha256', $payload, session_signing_key($root)); +} + +function read_session_cookie(string $root): ?array { + $token = strtolower((string)($_COOKIE[SESSION_COOKIE_NAME] ?? '')); + $parts = explode('.', $token); + if (count($parts) !== 3) return null; + [$playerId, $secret, $mac] = $parts; + if (!valid_player_id($playerId) || !valid_player_secret($secret) || preg_match('/^[0-9a-f]{64}$/', $mac) !== 1) return null; + $payload = $playerId . '.' . $secret; + $expected = hash_hmac('sha256', $payload, session_signing_key($root)); + return hash_equals($expected, $mac) ? ['playerId' => $playerId, 'playerSecret' => $secret] : null; +} + +function set_session_cookie(string $root, string $playerId, string $secret): void { + $secure = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off'); + setcookie(SESSION_COOKIE_NAME, make_session_cookie($root, $playerId, $secret), [ + 'expires' => time() + SESSION_COOKIE_MAX_AGE, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Strict', + ]); +} + +function stored_owner_matches(string $root, string $playerId, string $secret): bool { + $path = player_path($root, $playerId); + if (!is_file($path)) return false; + try { + $player = normalize_player(read_json_file($path, []), $playerId); + } catch (RuntimeException) { + return false; + } + $stored = (string)($player['ownerHash'] ?? ''); + return $stored !== '' && hash_equals($stored, hash('sha256', strtolower($secret))); +} + +function resolve_session_credentials(string $root, string $submittedPlayerId, string $submittedSecret, string $legacyPlayerId): array { + if (valid_legacy_player_id($legacyPlayerId) && valid_player_secret($submittedSecret)) { + $migratedPlayerId = legacy_player_uuid($legacyPlayerId); + $path = player_path($root, $migratedPlayerId); + if (is_file($path)) { + $raw = read_json_file($path, []); + $claimHash = strtolower((string)($raw['legacyClaimHash'] ?? '')); + if ($claimHash !== '' && hash_equals($claimHash, hash('sha256', $legacyPlayerId))) { + $ownerHash = strtolower((string)($raw['ownerHash'] ?? '')); + $submittedOwnerHash = hash('sha256', strtolower($submittedSecret)); + if ($ownerHash !== '' && !hash_equals($ownerHash, $submittedOwnerHash)) { + // A valid durable identity or HttpOnly cookie must remain a + // recovery path even when stale legacy-ID storage survived + // but its generated secret did not. Otherwise the stale + // legacy value permanently blocks an already-authenticated + // browser before cookie recovery is considered. + if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret) + && stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) { + return [ + 'playerId' => strtolower($submittedPlayerId), + 'playerSecret' => strtolower($submittedSecret), + 'created' => false, + ]; + } + $cookie = read_session_cookie($root); + if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) { + return [ + 'playerId' => $cookie['playerId'], + 'playerSecret' => $cookie['playerSecret'], + 'created' => false, + ]; + } + respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403); + } + return [ + 'playerId' => $migratedPlayerId, + 'playerSecret' => strtolower($submittedSecret), + 'created' => false, + 'legacyClaimId' => $legacyPlayerId, + ]; + } + } + } + if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret) + && stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) { + return ['playerId' => strtolower($submittedPlayerId), 'playerSecret' => strtolower($submittedSecret), 'created' => false]; + } + $cookie = read_session_cookie($root); + if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) { + return ['playerId' => $cookie['playerId'], 'playerSecret' => $cookie['playerSecret'], 'created' => false]; + } + claim_identity_issue_slot($root, client_ip()); + return ['playerId' => random_uuid_v4(), 'playerSecret' => bin2hex(random_bytes(32)), 'created' => true]; +} + +$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405); $payload = []; @@ -365,137 +1033,195 @@ if ($method === 'POST') { if ($declaredLength > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); $raw = file_get_contents('php://input', false, null, 0, MAX_REQUEST_BYTES + 1); if (!is_string($raw) || strlen($raw) > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); - try { - $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); - } catch (JsonException) { - respond(['ok' => false, 'error' => 'invalid_json'], 400); - } + try { $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); } + catch (JsonException) { respond(['ok' => false, 'error' => 'invalid_json'], 400); } if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400); $payload = $decoded; } -$action = $method === 'POST' - ? (string)($payload['action'] ?? '') - : (string)($_GET['action'] ?? 'summary'); -$playerId = $method === 'POST' - ? (string)($payload['playerId'] ?? '') - : (string)($_GET['playerId'] ?? ''); +$action = $method === 'POST' ? (string)($payload['action'] ?? '') : (string)($_GET['action'] ?? 'summary'); +if (!in_array($action, ['session', 'sync', 'reset', 'summary'], true)) respond(['ok' => false, 'error' => 'invalid_action'], 400); +if ($method === 'GET' && $action !== 'summary') respond(['ok' => false, 'error' => 'method_not_allowed'], 405); -if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) { - respond(['ok' => false, 'error' => 'invalid_action'], 400); -} -if ($method === 'GET' && $action !== 'summary') { - respond(['ok' => false, 'error' => 'method_not_allowed'], 405); -} -if (!valid_player_id($playerId)) { - respond(['ok' => false, 'error' => 'invalid_player'], 400); -} -$playerId = strtolower($playerId); -$syncUnlocks = []; -$completionistEligible = null; -if ($action === 'unlock') { - $achievementId = (string)($payload['achievementId'] ?? ''); - if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) { - respond(['ok' => false, 'error' => 'invalid_achievement'], 400); - } -} elseif ($action === 'sync') { - $submittedUnlocks = $payload['unlocked'] ?? null; - if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) { - respond(['ok' => false, 'error' => 'invalid_unlocks'], 400); - } - if (array_key_exists('completionistEligible', $payload)) { - if (!is_bool($payload['completionistEligible'])) { - respond(['ok' => false, 'error' => 'invalid_completionist_state'], 400); - } - $completionistEligible = $payload['completionistEligible']; - } - foreach ($submittedUnlocks as $achievementId => $timestamp) { - if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) { - respond(['ok' => false, 'error' => 'invalid_achievement'], 400); - } - if (!is_int($timestamp) && !is_float($timestamp) && !is_string($timestamp)) { - respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); - } - $numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT); - if ($numericTimestamp === false || !is_finite((float)$numericTimestamp) || $numericTimestamp <= 0) { - respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); - } - $syncUnlocks[$achievementId] = (float)$numericTimestamp; - } - if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']); -} - -$dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME; -if (!is_dir($dataDir) && !mkdir($dataDir, 0775, true) && !is_dir($dataDir)) { - respond(['ok' => false, 'error' => 'storage_unavailable'], 503); -} -$dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME; -$lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME; -$lockHandle = @fopen($lockPath, 'c'); -if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) { - if (is_resource($lockHandle)) fclose($lockHandle); - respond(['ok' => false, 'error' => 'storage_locked'], 503); -} - -$summary = null; -$storageError = null; try { - $contents = is_file($dataPath) ? file_get_contents($dataPath) : ''; - if ($contents === false) throw new RuntimeException('storage_unavailable'); - [$state, $sourceFormat] = decode_stored_state($contents); - $now = time(); - $mutatesPlayer = $action !== 'summary'; - - if ($mutatesPlayer) { - $player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId]) - ? $state['players'][$playerId] - : ['firstSeen' => $now]; - $player['lastSeen'] = $now; - $submittedGameVersion = substr((string)($payload['gameVersion'] ?? ''), 0, 32); - if ($submittedGameVersion !== '' || !isset($player['gameVersion'])) $player['gameVersion'] = $submittedGameVersion; - $state['players'][$playerId] = $player; - } - - if ($action === 'unlock') { - $achievementId = (string)$payload['achievementId']; - if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) { - $state['unlocks'][$achievementId] = []; - } - if (!isset($state['unlocks'][$achievementId][$playerId])) { - $state['unlocks'][$achievementId][$playerId] = $now; - } - } elseif ($action === 'sync') { - if ($completionistEligible === false && isset($state['unlocks']['true_tarinai_observer'][$playerId])) { - unset($state['unlocks']['true_tarinai_observer'][$playerId]); - } - foreach ($syncUnlocks as $achievementId => $submittedTimestamp) { - if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) { - $state['unlocks'][$achievementId] = []; - } - if (isset($state['unlocks'][$achievementId][$playerId])) continue; - $timestampSeconds = $submittedTimestamp >= 100000000000 - ? (int)floor($submittedTimestamp / 1000) - : (int)floor($submittedTimestamp); - $state['unlocks'][$achievementId][$playerId] = max(1, min($now, $timestampSeconds)); - } - } elseif ($action === 'reset') { - foreach ($state['unlocks'] as $achievementId => $records) { - if (is_array($records)) unset($state['unlocks'][$achievementId][$playerId]); - } - } - - $encoded = encode_compact_state($state); - if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) { - atomic_write_state($dataPath, $encoded); - } - $summary = build_summary($state); + $root = data_root(); + protect_data_root($root); + $summaryPath = $root . DIRECTORY_SEPARATOR . 'summary.json'; + $globalLock = $root . DIRECTORY_SEPARATOR . 'summary.lock'; + with_lock($globalLock, LOCK_EX, fn() => migrate_legacy_compact_state_v4($root, $summaryPath)); } catch (RuntimeException $error) { - $known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed']; - $storageError = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; -} finally { - flock($lockHandle, LOCK_UN); - fclose($lockHandle); + $known = [ + 'storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked', + 'legacy_state_unreadable', 'legacy_state_invalid', 'legacy_catalog_mismatch', 'legacy_backup_failed', + 'legacy_player_collision', 'legacy_migration_verify_failed', + ]; + $message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; + respond(['ok' => false, 'error' => $message], 503); +} +if ($action === 'summary') { + enforce_rate_limit($root, 'ip:' . client_ip() . ':read', RATE_READ_LIMIT); +} elseif ($action === 'session') { + enforce_rate_limit($root, 'ip:' . client_ip() . ':session', RATE_SESSION_IP_LIMIT); +} else { + // Authenticated writes are primarily player-scoped. The high IP ceiling is + // only an abuse fuse and does not make a classroom share a 30-request pool. + enforce_rate_limit($root, 'ip:' . client_ip() . ':write-abuse', RATE_WRITE_IP_LIMIT); +} +if ($action === 'summary') { + try { + $result = with_lock($globalLock, LOCK_EX, fn() => public_summary(repair_summary_cache($root, $summaryPath))); + respond($result); + } catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); } } -if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503); -respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503); +$submittedPlayerId = strtolower((string)($payload['playerId'] ?? '')); +$submittedSecret = strtolower((string)($payload['playerSecret'] ?? '')); +$submittedLegacyPlayerId = (string)($payload['legacyPlayerId'] ?? ''); +$sessionCredentials = null; +if ($action === 'session') { + try { $sessionCredentials = resolve_session_credentials($root, $submittedPlayerId, $submittedSecret, $submittedLegacyPlayerId); } + catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); } + $playerId = $sessionCredentials['playerId']; + $secret = $sessionCredentials['playerSecret']; +} else { + $playerId = $submittedPlayerId; + $secret = $submittedSecret; + if (!valid_player_id($playerId)) respond(['ok' => false, 'error' => 'invalid_player'], 400); + if (!valid_player_secret($secret)) respond(['ok' => false, 'error' => 'invalid_owner'], 403); + if (!is_file(player_path($root, $playerId))) respond(['ok' => false, 'error' => 'unknown_player'], 403); +} +enforce_rate_limit($root, 'player:' . $playerId, RATE_PLAYER_WRITE_LIMIT); +$clientGeneration = max(0, (int)($payload['generation'] ?? 0)); +$generationRecovery = (bool)($payload['generationRecovery'] ?? false); +$submittedUnlocks = []; +$submittedProgress = default_progress_state(); + +if ($action === 'sync') { + if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) { + respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426); + } + if ((string)($payload['mergeMode'] ?? '') !== 'grow-only-union-v5') { + respond(['ok' => false, 'error' => 'invalid_merge_mode'], 400); + } + $rawUnlocks = $payload['unlocked'] ?? null; + if (!is_array($rawUnlocks) || count($rawUnlocks) > count(ACHIEVEMENT_IDS)) respond(['ok' => false, 'error' => 'invalid_unlocks'], 400); + if ((bool)($payload['replace'] ?? false)) respond(['ok' => false, 'error' => 'destructive_sync_disabled'], 409); + $rawProgress = $payload['progressState'] ?? null; + if (!is_array($rawProgress)) respond(['ok' => false, 'error' => 'invalid_progress'], 400); + $submittedProgress = normalize_progress_state($rawProgress); + $receivedAt = time(); + foreach ($rawUnlocks as $id => $timestamp) { + if (!is_string($id) || !in_array($id, ACHIEVEMENT_IDS, true)) respond(['ok' => false, 'error' => 'invalid_achievement'], 400); + if ($id === COMPLETIONIST_ID) continue; // completionist is always server-derived. + $submittedUnlocks[$id] = normalize_client_unlock_time($timestamp, $receivedAt); + } +} +if ($action === 'reset') { + if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) { + respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426); + } + if ((string)($payload['confirmReset'] ?? '') !== 'RESET_ACHIEVEMENTS') respond(['ok' => false, 'error' => 'reset_confirmation_required'], 409); + if ((string)($payload['catalogRevision'] ?? '') !== CATALOG_REVISION) respond(['ok' => false, 'error' => 'catalog_mismatch'], 409); +} + +try { + $result = with_lock($globalLock, LOCK_EX, function () use ($root, $summaryPath, $playerId, $secret, $payload, $action, $submittedUnlocks, $submittedProgress, $sessionCredentials, $clientGeneration, $generationRecovery): array { + $now = time(); + $summary = default_summary(); + $path = player_path($root, $playerId); + $exists = is_file($path); + if (!$exists && $action !== 'session') respond(['ok' => false, 'error' => 'unknown_player'], 403); + $ownerHash = hash('sha256', $secret); + $player = $exists ? normalize_player(read_json_file($path, []), $playerId) : default_player($playerId, $ownerHash, $now); + $legacyClaimId = (string)($sessionCredentials['legacyClaimId'] ?? ''); + if ($action === 'session' && $legacyClaimId !== '') { + $expectedClaimHash = hash('sha256', $legacyClaimId); + if (!hash_equals((string)($player['legacyClaimHash'] ?? ''), $expectedClaimHash)) { + respond(['ok' => false, 'error' => 'legacy_claim_invalid'], 403); + } + $storedOwnerHash = (string)($player['ownerHash'] ?? ''); + if ($storedOwnerHash !== '' && !hash_equals($storedOwnerHash, $ownerHash)) { + respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403); + } + $player['ownerHash'] = $ownerHash; + if ((int)($player['legacyClaimedAt'] ?? 0) <= 0) $player['legacyClaimedAt'] = $now; + } + verify_owner($player, $secret); + + $beforeQualified = (bool)$player['qualified']; + $beforeUnlocks = $player['unlocked']; + $serverGeneration = max(0, (int)($player['generation'] ?? 0)); + + if ($action === 'sync') { + if ($clientGeneration < $serverGeneration) { + $summary = repair_summary_cache($root, $summaryPath); + $error = public_summary($summary, $player); + $error['ok'] = false; + $error['error'] = 'stale_generation'; + respond($error, 409); + } + if ($clientGeneration > $serverGeneration) { + if (!$generationRecovery) { + $summary = repair_summary_cache($root, $summaryPath); + $error = public_summary($summary, $player); + $error['ok'] = false; + $error['error'] = 'generation_ahead'; + respond($error, 409); + } + // A locally durable newer generation is authoritative after a + // server backup restore or a newly-issued replacement identity. + $player['generation'] = $clientGeneration; + $player['unlocked'] = []; + $player['progressState'] = default_progress_state(); + $serverGeneration = $clientGeneration; + } + } + + $player['lastSeen'] = $now; + $player['activityCount'] = max(0, (int)$player['activityCount']) + 1; + $version = substr((string)($payload['gameVersion'] ?? ''), 0, 32); + if ($version !== '') $player['gameVersion'] = $version; + + if ($action === 'sync') { + $player['progressState'] = merge_progress_state($player['progressState'] ?? null, $submittedProgress); + foreach ($submittedUnlocks as $id => $submittedAt) { + if (!isset($player['unlocked'][$id])) $player['unlocked'][$id] = $submittedAt; + else $player['unlocked'][$id] = min((int)$player['unlocked'][$id], $submittedAt); + } + // Cumulative achievements are independently reconstructed from the + // merged progress CRDT, so 99 actions on one device plus 1 on + // another unlocks exactly once without trusting request ordering. + reconcile_progress_unlocks($player['unlocked'], $player['progressState'], $now); + } elseif ($action === 'reset') { + // The client generation is the target generation. Equal or older + // reset requests are already-processed retries and must be no-ops. + if ($clientGeneration > $serverGeneration) { + $player['generation'] = $clientGeneration; + $player['unlocked'] = []; + $player['progressState'] = default_progress_state(); + } + } + reconcile_completionist($player['unlocked'], $now); + + if ($beforeQualified) apply_summary_delta($summary, $beforeUnlocks, $player['unlocked']); + maybe_qualify($player, $summary, $now); + + atomic_write_json($path, $player); + $summary = repair_summary_cache($root, $summaryPath); + $public = public_summary($summary, $player); + if ($action === 'session') { + $public['playerId'] = $playerId; + $public['playerSecret'] = $secret; + $public['serverIssued'] = (bool)($sessionCredentials['created'] ?? false); + $public['legacyClaimed'] = (string)($sessionCredentials['legacyClaimId'] ?? '') !== ''; + } + return $public; + }); + if ($action === 'session') set_session_cookie($root, $playerId, $secret); + respond($result); + +} catch (RuntimeException $error) { + $known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked']; + $message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; + respond(['ok' => false, 'error' => $message], 503); +} diff --git a/app_manifest.json b/app_manifest.json index 67dee34..30886fe 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -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", diff --git a/assets/ui/pwa-icon-192.png b/assets/ui/pwa-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..480d662e6293709c6971859e31c37557fc3b0f85 GIT binary patch literal 26366 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 ThZlaY4M5?es%(|CN$~#w1-PO| literal 0 HcmV?d00001 diff --git a/assets/ui/pwa-icon-512.png b/assets/ui/pwa-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..f84c2de0a5318db7c5bbfb2eb9453649f322cb44 GIT binary patch literal 119075 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⁡#0JELy=(ul5Om-3DZwrJgFaz%2r<*YAyUm-jbc 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

    #sobL7`I!Ghi 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 literal 0 HcmV?d00001 diff --git a/css/mobile.css b/css/mobile.css index fa953da..9b35acf 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -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; + } +} diff --git a/index.html b/index.html index 6c3a7ec..55f92be 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,16 @@ たりない観察 - - - - - - - - + + + + + + + + + +
    @@ -56,14 +58,14 @@
    - +
    -

    @@ -214,7 +216,7 @@
    たりない観察
    -

    実績 0 / 75

    +

    実績 0 / 77

    @@ -305,175 +307,176 @@
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievement_catalog.js b/js/achievement_catalog.js new file mode 100644 index 0000000..56dd5ac --- /dev/null +++ b/js/achievement_catalog.js @@ -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": "匹" + } + } + ] +}; diff --git a/js/achievements.js b/js/achievements.js index 65f7398..2bdfa13 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -1,216 +1,87 @@ "use strict"; (function initAchievementSystem(global) { - const STORAGE_KEY = "tarinai_achievements_v2"; + // v7 uses a fresh namespace so tabs left open on older builds cannot write + // stale full-state envelopes into the current persistence layer. + const STORAGE_KEY = "tarinai_achievements_v7"; + const STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v7"; + const STORAGE_SESSION_KEY = "tarinai_achievements_session_v7"; + const PREVIOUS_STORAGE_KEY = "tarinai_achievements_v6"; + const PREVIOUS_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v6"; + const PREVIOUS_STORAGE_SESSION_KEY = "tarinai_achievements_session_v6"; + const OLDER_STORAGE_KEY = "tarinai_achievements_v5"; + const OLDER_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v5"; + const OLDER_STORAGE_SESSION_KEY = "tarinai_achievements_session_v5"; + const LEGACY_STORAGE_KEY = "tarinai_achievements_v4"; + const LEGACY_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v4"; + const LEGACY_STORAGE_SESSION_KEY = "tarinai_achievements_session_v4"; + const ANCIENT_STORAGE_KEY = "tarinai_achievements_v2"; + const ANCIENT_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v3"; + const ANCIENT_STORAGE_SESSION_KEY = "tarinai_achievements_session_v3"; + const PERSISTENCE_FORMAT = "tarinai-achievements-envelope-v1"; + const PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v5"; + const PREVIOUS_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v4"; + const OLDER_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v3"; + const LEGACY_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v2"; + const ANCIENT_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v1"; + const PERSISTENCE_DB_STORE = "records"; + const PERSISTENCE_DB_KEY = "achievement-state"; + const PERSISTENCE_CHANNEL_NAME = "tarinai-achievements-persistence-v5"; + const PREHYDRATION_JOURNAL_PREFIX = "tarinai_achievements_pre_hydration_v7_"; + const PREHYDRATION_JOURNAL_FORMAT = "tarinai-achievements-pre-hydration-v1"; + const PREHYDRATION_JOURNAL_DB_PREFIX = "pre-hydration-journal:"; const PLAYER_KEY = "tarinai_achievement_player_v1"; + const PLAYER_SECRET_KEY = "tarinai_achievement_player_secret_v1"; + const LEGACY_PLAYER_KEY = "tarinai_achievement_legacy_player_v4"; const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php"); - const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.77"); + const GAME_VERSION = String(global.TARINAI_VERSION || "39.18.3"); const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug")); - const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const LEGACY_PLAYER_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; + const PLAYER_SECRET_PATTERN = /^[0-9a-f]{64}$/i; const ABSOLUTE_ZERO_C = -273.15; const CONTINUOUS_PLAY_TARGET_MS = 60 * 60 * 1000; const CONTINUOUS_PLAY_24H_TARGET_MS = 24 * 60 * 60 * 1000; const HELD_TARGET_MS = 30 * 1000; - const COMPLETIONIST_ID = "true_tarinai_observer"; + const ACHIEVEMENT_CATALOG = global.TARINAI_ACHIEVEMENT_CATALOG; + if (!ACHIEVEMENT_CATALOG || !Array.isArray(ACHIEVEMENT_CATALOG.definitions) || !Array.isArray(ACHIEVEMENT_CATALOG.categories)) { + throw new Error("achievement catalog unavailable"); + } + const COMPLETIONIST_ID = String(ACHIEVEMENT_CATALOG.completionistId || "true_tarinai_observer"); + const ACHIEVEMENT_CATALOG_REVISION = String(ACHIEVEMENT_CATALOG.revision || "unknown"); const GENERATION_TARGET = 10; - const CHAOS_FIGHT_TARGET = 666; - const SNIPER_SHOT_TARGET = 333; - const FERTILITY_BIRTH_TARGET = 721; - const DAILY_PLAY_TARGET = 7; - const WIRE_SHOCK_TARGET = 7; const DAY_MS = 24 * 60 * 60 * 1000; - const ROBOT_CLEAN_TARGET = 200; - const ROBOT_ALL_TARGET_MASK = 0x7f; const SYNC_DEBOUNCE_MS = 450; const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000; - const SPELL_STATE_VERSION = 8; - const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]); - const DIRECT_TREATMENT_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]); + const SYNC_PROTOCOL_VERSION = 5; + const SPELL_STATE_VERSION = 11; // v11 stores additive counters only in the canonical ledger. + const MEDICINE_LEDGER_TYPES = Object.freeze([...(ACHIEVEMENT_CATALOG.medicineLedgerTypes || [])]); + const LINK_PROGRESS_TYPES = Object.freeze([...(ACHIEVEMENT_CATALOG.linkProgressTypes || [])]); + const ADDITIVE_PROGRESS_CAPS = Object.freeze({ ...(ACHIEVEMENT_CATALOG.additiveProgressCaps || {}) }); + const ADDITIVE_PROGRESS_KEYS = Object.freeze(Object.keys(ADDITIVE_PROGRESS_CAPS)); + const CHAOS_FIGHT_TARGET = ADDITIVE_PROGRESS_CAPS.fightMochiFightCount; + const SNIPER_SHOT_TARGET = ADDITIVE_PROGRESS_CAPS.shotCount; + const FERTILITY_BIRTH_TARGET = ADDITIVE_PROGRESS_CAPS.loveMochiBirthCount; + const DAILY_PLAY_TARGET = 7; + const WIRE_SHOCK_TARGET = 7; + const ROBOT_CLEAN_TARGET = ADDITIVE_PROGRESS_CAPS.robotCleanCount; + const ROBOT_ALL_TARGET_MASK = 0x7f; const OBSERVER_GAME_DAYS = 3; const STRENGTH_IN_NUMBERS_TARGET = 100; const ELITE_FEW_MAX_POPULATION = 25; const ELITE_FEW_TARGET_DAYS = 5; - const STATISTICIAN_TARGET = 30; - const LIVELY_MAKING_TARGET = 100; - const MEMENTO_MORI_TARGET = 10000; - const MAD_SCIENTIST_TARGET = 10; + const STATISTICIAN_TARGET = ADDITIVE_PROGRESS_CAPS.statsButtonPressCount; + const LIVELY_MAKING_TARGET = ADDITIVE_PROGRESS_CAPS.manualTarinaiAddedCount; + const MEMENTO_MORI_TARGET = ADDITIVE_PROGRESS_CAPS.totalDeathCount; + const MAD_SCIENTIST_TARGET = Number(ACHIEVEMENT_CATALOG.definitions.find(def => def.id === "mad_scientist")?.progress?.target || 10); const FULL_SEASON_CYCLE_DAYS = 20; - const PRE_UNLOCK_DESCRIPTION_IDS = new Set([ - "natural_zunchi_slave", - "natural_tarinai_king", - "revolution", - "laxative_starvation", - "natural_zunchi_slave_5_generations", - "natural_tarinai_king_3_generations", - "first_birth", - "lifespan_completed", - "birth_50_in_60_seconds", - "death_50_in_10_seconds", - "all_non_sleep_diseased_25", - "colony_happy", - "safe_colony_25_5_minutes", - "ants_alive_25", - "great_mother_1000_births", - "continuous_play_1_hour", - "low_fps_single_digit", - "continuous_play_24_hours", - "robot_cleaner_100", - "mercury_lifespan", - "enemy_enemy_friend", - "eternal_history_generation_10", - "town_doctor_50", - "chaos_seeker_666_fights", - "fertility_seeker_721_love_births", - "daily_play_7_days", - "elite_few", - "lively_making", - "memento_mori", - "rain_shelter_all", - "self_zunchi_death", - "zunchi_collision_death", - "megalopolis", - ]); - const ACHIEVEMENT_CATEGORIES = Object.freeze([ - Object.freeze({ - id: "ecology", - title: "\u751f\u614b", - ids: Object.freeze([ - "natural_zunchi_slave", "natural_tarinai_king", "lifespan_completed", - "all_non_sleep_diseased_25", "colony_happy", "direct_feed_33", - "safe_colony_25_5_minutes", "ants_alive_25", "overprotective", - "self_sufficient", "rain_shelter_all", "medicine_ledger_all", - "enemy_enemy_friend", "king_full_satisfaction", "slave_zero_satisfaction", - "town_doctor_50", "ant_nest_without_tarinai", "zunchi_overflow", - "comfortable_beds", "stone_pillow", "favorite_one", - ]), - }), - Object.freeze({ - id: "population", - title: "\u4eba\u53e3", - ids: Object.freeze([ - "first_birth", "natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations", - "birth_50_in_60_seconds", "great_mother_1000_births", "eternal_history_generation_10", - "fertility_seeker_721_love_births", "strength_in_numbers", "elite_few", - "lively_making", "memento_mori", - ]), - }), - Object.freeze({ - id: "experiment", - title: "\u5b9f\u9a13", - ids: Object.freeze([ - "self_zunchi_death", "zunchi_collision_death", "death_50_in_10_seconds", "soccer_ball_death", - "fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation", - "ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge", - "mercury_lifespan", "revolution", "fuel_to_fire", - "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", "mad_scientist", - ]), - }), - Object.freeze({ - id: "construction", - title: "\u5efa\u7bc9", - ids: Object.freeze([ - "placed_objects_100", "mechanized_industry", "information_industry", "robot_cleaner_100", - "minimalist_happy", "unplanned_city_30", "clean_freak_robot_only", "megalopolis", - "sandbox_five_toilets", "park_ground_changed", "ground_change_4_in_1_second", - ]), - }), - Object.freeze({ - id: "operation", - title: "\u64cd\u4f5c", - ids: Object.freeze([ - "idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second", - "undo_mass_revival", "held_30_seconds", "undo_20", "redo_20", - "poke_plushie_fling", "sniper_333_shots", "statistician", "well_informed", - ]), - }), - Object.freeze({ - id: "other", - title: "\u305d\u306e\u4ed6", - ids: Object.freeze([ - "low_fps_single_digit", "daily_play_7_days", "continuous_play_24_hours", - "continuous_play_1_hour", "across_seasons", "true_tarinai_observer", - ]), - }), - ]); - const DEFINITIONS = Object.freeze([ - Object.freeze({ id: "first_birth", title: "\u306f\u3058\u3081\u3066\u306e\u7e41\u6b96", description: "\u305f\u308a\u306a\u3044\u306e\u7e41\u6b96\u30921\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "natural_zunchi_slave", title: "\u305a\u3093\u3061\u3069\u308c\u3044", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u51fa\u73fe\u3059\u308b\u3002" }), - Object.freeze({ id: "natural_tarinai_king", title: "\u305f\u308a\u306a\u3044\u738b", description: "\u305f\u308a\u306a\u3044\u738b\u304c\u51fa\u73fe\u3059\u308b\u3002" }), - Object.freeze({ id: "self_zunchi_death", title: "\u3042\u3041\uff3e\uff5e \u305f\u307e\u3089\u306d\u3047\u305c", description: "\u304a\u3057\u308a\u92f2\u3092\u629c\u3044\u305f\u6642\u3001\u81ea\u5206\u306e\u305a\u3093\u3061\u3067\u81ea\u7206\u3059\u308b\u3002" }), - Object.freeze({ id: "zunchi_collision_death", title: "\u304f\u3061\u3083\u3044\u306e\u3060\uff01", description: "\u305a\u3093\u3061\u3068\u306e\u885d\u7a81\u3067\u6b7b\u4ea1\u3059\u308b\u500b\u4f53\u304c\u51fa\u73fe\u3059\u308b\u3002" }), - Object.freeze({ id: "natural_zunchi_slave_5_generations", title: "\u672b\u4ee3\u307e\u3067\u306e\u6065", description: "5\u4ee3\u9023\u7d9a\u3067\u305a\u3093\u3061\u3069\u308c\u3044\uff08\u9396\u306a\u3057\uff09" }), - Object.freeze({ id: "natural_tarinai_king_3_generations", title: "\u83ef\u9e97\u306a\u308b\u738b\u65cf", description: "3\u4ee3\u9023\u7d9a\u3067\u305f\u308a\u306a\u3044\u738b\uff08\u738b\u51a0\u306a\u3057\uff09" }), - Object.freeze({ id: "death_50_in_10_seconds", title: "6\u756a\u76ee\u306e\u5927\u91cf\u7d76\u6ec5", description: "10\u79d2\u4ee5\u5185\u306b50\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }), - Object.freeze({ id: "birth_50_in_60_seconds", title: "\u30d9\u30d3\u30fc\u30d6\u30fc\u30e0", description: "1\u5206\u4ee5\u5185\u306b30\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u8a95\u751f\u3059\u308b\u3002" }), - Object.freeze({ id: "great_mother_1000_births", title: "\u6bcd\u306a\u308b\u5927\u5730", description: "\u305f\u308a\u306a\u3044\u3092\u7e41\u6b96\u3067\u7d2f\u8a083333\u4f53\u8a95\u751f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "lifespan_completed", title: "\u5929\u5bff", description: "\u305f\u308a\u306a\u30441\u4f53\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }), - Object.freeze({ id: "soccer_ball_death", title: "\u8c46\u91ce\u3001\u6bba\u30c3\u30ab\u30fc\u3084\u308d\u3046\u305c\uff01", description: "\u30b5\u30c3\u30ab\u30fc\u30dc\u30fc\u30eb\u3068\u306e\u885d\u7a81\u3067\u3001\u305f\u308a\u306a\u30441\u4f53\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }), - Object.freeze({ id: "fight_pair_danger_kill", title: "\u55a7\u5629\u4e21\u6210\u6557", description: "\u55a7\u5629\u4e2d\u306e\u305f\u308a\u306a\u3044\u3092\u3001\u4e21\u65b9\u3068\u3082\u5371\u967a\u30ab\u30c6\u30b4\u30ea\u306e\u30a2\u30a4\u30c6\u30e0\u3067\u5012\u3059\u3002" }), - Object.freeze({ id: "all_non_sleep_diseased_25", title: "\u7dca\u6025\u4e8b\u614b\u5ba3\u8a00", description: "25\u4f53\u4ee5\u4e0a\u304c\u5b58\u547d\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u4e0a\u306e\u5168\u54e1\u304c\u306d\u3080\u308a\u75c5\u4ee5\u5916\u306e\u75c5\u6c17\u306b\u304b\u304b\u308b\u3002" }), - Object.freeze({ id: "colony_happy", title: "\u7ba1\u7406\u3055\u308c\u305f\u697d\u5712", description: "\u30b3\u30ed\u30cb\u30fc\u72b6\u614b\u3092\u3057\u3070\u3089\u304f\u300c\u5e78\u798f\u300d\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "direct_feed_33", title: "\u990c\u3084\u308a\u3058\u3044\u3055\u3093", description: "\u624b\u304b\u3089\u76f4\u63a533\u56de\u990c\u3092\u4e0e\u3048\u308b\u3002" }), - Object.freeze({ id: "ignite_during_birth_ritual", title: "\u71c3\u3048\u4e0a\u304c\u308b\u604b", description: "\u305f\u308a\u306a\u3044\u304c\u30d8\u30b3\u30d8\u30b3\u4e2d\u306b\u7740\u706b\u3059\u308b\u3002" }), - Object.freeze({ id: "laxative_starvation", title: "\u5185\u81d3\u5168\u90e8\u51fa\u305f", description: "\u4e0b\u5264\u306e\u52b9\u679c\u4e2d\u306b\u9913\u6b7b\u3059\u308b\u3002" }), - Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "3\u30b2\u30fc\u30e0\u65e5\u3001\u64cd\u4f5c\u305b\u305a\u753b\u9762\u3092\u898b\u308b\u3060\u3051" }), - Object.freeze({ id: "placed_objects_100", title: "\u7269\u3067\u3044\u3063\u3071\u3044", description: "\u914d\u7f6e\u7269\u3092\u7d2f\u8a08100\u500b\u7f6e\u304f\u3002" }), - Object.freeze({ id: "mechanized_industry", title: "\u6a5f\u68b0\u5316\u7523\u696d", description: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3067\u63a5\u7d9a\u5148\u3092\u4f5c\u52d5\u3055\u305b\u3001\u30ed\u30fc\u30d7\u30fb\u68d2\u30fb\u3070\u306d\u3092\u305d\u308c\u305e\u308c1\u672c\u4ee5\u4e0a\u8a2d\u7f6e\u3059\u308b\u3002" }), - Object.freeze({ id: "safe_colony_25_5_minutes", title: "\u5b89\u5168\u7b2c\u4e00", description: "25\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3092\u30b2\u30fc\u30e0\u51855\u5206\u9593\u7dad\u6301\u3057\u3001\u305d\u306e\u9593\u5bff\u547d\u4ee5\u5916\u3067\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\u3002" }), - Object.freeze({ id: "ants_alive_25", title: "\u30a2\u30ea\u306e\u60d1\u661f", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u30a2\u30ea\u304c25\u4f53\u306b\u5230\u9054\u3059\u308b\u3002" }), - Object.freeze({ id: "ants_killed_100", title: "\u5bb3\u866b\u99c6\u9664", description: "\u30a2\u30ea\u3092\u7d2f\u8a08100\u4f53\u5012\u3059\u3002" }), - Object.freeze({ id: "secret_collection_9_slots", title: "\u79d8\u5bc6\u306e\u30b3\u30ec\u30af\u30b7\u30e7\u30f3", description: "\u30bb\u30fc\u30d6\u30b9\u30ed\u30c3\u30c89\u3064\u3092\u3059\u3079\u3066\u57cb\u3081\u308b\u3002" }), - Object.freeze({ id: "pause_spam_4_in_1_second", title: "\u4f55\u3084\u3063\u3066\u308b\u306e\uff1f", description: "\u4f55\u3082\u306a\u3044\u90e8\u5206\u30921\u79d2\u9593\u306b4\u56de\u9023\u6253\u3059\u308b\u3002" }), - Object.freeze({ id: "continuous_play_1_hour", title: "\u3081\u3063\u3061\u3083\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "1\u6642\u9593\u9023\u7d9a\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), - Object.freeze({ id: "below_absolute_zero_item", title: "\u7269\u7406\u6cd5\u5247\u3092\u4e0b\u5074\u306b\u8d85\u8d8a", description: "\u6e29\u5ea6\u8a08\u7b97\u3055\u308c\u308b\u30a2\u30a4\u30c6\u30e0\u30921\u3064\u7d76\u5bfe\u96f6\u5ea6\u3092\u4e0b\u56de\u308b\u6e29\u5ea6\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "undo_mass_revival", title: "\u898b\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3057\u3088\u3046", description: "\u4e00\u5ea6\u306eUndo\u306710\u4f53\u4ee5\u4e0a\u306e\u6b7b\u4ea1\u3092\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "robot_cleaner_100", title: "\u6e05\u6383\u696d\u8005", description: "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u305a\u3093\u3061\u30fb\u6b7b\u9ab8\u30fb\u6c5a\u308c\u3092\u7d2f\u8a08200\u500b\u51e6\u7406\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "held_30_seconds", title: "\u7a7a\u4e2d\u751f\u6d3b", description: "\u540c\u3058\u305f\u308a\u306a\u3044\u309230\u79d2\u9593\u3064\u307e\u307f\u7d9a\u3051\u308b\u3002" }), - Object.freeze({ id: "minimalist_happy", title: "\u30df\u30cb\u30de\u30ea\u30b9\u30c8", description: "\u30d7\u30ec\u30a4\u958b\u59cb6\u65e5\u76ee\u4ee5\u964d\u3001\u914d\u7f6e\u7269\u309210\u500b\u4ee5\u4e0b\u306b\u6291\u3048\u3066\u30b3\u30ed\u30cb\u30fc\u72b6\u614b\u3092\u300c\u5e78\u798f\u300d\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "overprotective", title: "\u904e\u4fdd\u8b77", description: "\u540c\u3058\u305f\u308a\u306a\u3044\u306b\u624b\u304b\u308910\u56de\u990c\u3092\u4e0e\u3048\u30013\u56de\u4ee5\u4e0a\u85ac\u3067\u6cbb\u7642\u3059\u308b\u3002" }), - Object.freeze({ id: "self_sufficient", title: "\u81ea\u7d66\u81ea\u8db3", description: "\u8907\u88fd\u6a5f\u304c\u306a\u3044\u72b6\u614b\u3067\u300120\u4f53\u4ee5\u4e0a\u3092\u76f4\u63a5\u7d66\u990c\u305b\u305a10\u5206\u9593\u7dad\u6301\u3059\u308b\u3002" }), - Object.freeze({ id: "unplanned_city_30", title: "\u7121\u8a08\u753b\u90fd\u5e02", description: "\u914d\u7f6e\u5f8c30\u79d2\u4ee5\u5185\u306b\u524a\u9664\u3059\u308b\u64cd\u4f5c\u3092\u7d2f\u8a0830\u56de\u884c\u3046\u3002" }), - Object.freeze({ id: "sauna_cold_plunge", title: "\u30b5\u30a6\u30ca\u6c34\u98a8\u5442", description: "\u6691\u3059\u304e\u308b\u72b6\u614b\u306b\u306a\u3063\u305f\u305f\u308a\u306a\u3044\u3092\u300115\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b\u72b6\u614b\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "rain_shelter_all", title: "\u96e8\u5bbf\u308a\u5b8c\u4e86", description: "\u96e8\u5929\u6642\u300120\u4f53\u4ee5\u4e0a\u306e\u5168\u54e1\u3092\u30d1\u30e9\u30bd\u30eb\u30fb\u5de3\u7bb1\u30fb\u571f\u7ba1\u3067\u96e8\u5bbf\u308a\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "medicine_ledger_all", title: "\u304a\u304f\u3059\u308a\u624b\u5e33\u5168\u57cb\u3081", description: "\u5168\u7a2e\u985e\u306e\u85ac\u3092\u5c11\u306a\u304f\u3068\u30821\u56de\u4f7f\u7528\u3059\u308b\u3002" }), - Object.freeze({ id: "mercury_lifespan", title: "\u4e0d\u8001\u4e0d\u6b7b\u307e\u3067\u3042\u3068\u4e00\u6b69", description: "\u6c34\u9280\u3092\u4f7f\u7528\u3057\u305f\u305f\u308a\u306a\u3044\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }), - Object.freeze({ id: "enemy_enemy_friend", title: "\u6575\u306e\u6575\u306f\u5473\u65b9", description: "\u305f\u308a\u306a\u304430\u4f53\u4ee5\u4e0a\u3092\u7121\u50b7\u3067\u4fdd\u3061\u306a\u304c\u3089\u3001\u5371\u967a\u30a2\u30a4\u30c6\u30e0\u3067\u30a2\u30ea\u309210\u4f53\u5012\u3059\u3002" }), - Object.freeze({ id: "revolution", title: "\u9769\u547d", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u3001\u305f\u308a\u306a\u3044\u738b\u3092\u55a7\u5629\u3067\u6b7b\u4ea1\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "fuel_to_fire", title: "\u706b\u306b\u6cb9", description: "\u3051\u3093\u304b\u9905\u3092\u4e0e\u3048\u305f\u305f\u308a\u306a\u3044\u304c30\u79d2\u4ee5\u5185\u306b\u55a7\u5629\u3092\u59cb\u3081\u308b\u3002" }), - Object.freeze({ id: "undo_20", title: "\u3084\u3063\u3071\u306a\u3057", description: "Undo\u3092\u7d2f\u8a0820\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "redo_20", title: "\u3084\u3063\u3071\u3042\u308a", description: "Redo\u3092\u7d2f\u8a0820\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "eternal_history_generation_10", title: "\u60a0\u4e45\u306e\u6b74\u53f2", description: "\u4e16\u4ee3\u304c10\u306b\u5230\u9054\u3059\u308b\u3002" }), - Object.freeze({ id: "king_full_satisfaction", title: "\u4e94\u4f53\u6e80\u8db3", description: "\u305f\u308a\u306a\u3044\u738b1\u500b\u4f53\u306b\u30d7\u30ed\u30c6\u30a4\u30f3\u3068\u5de8\u5927\u85ac\u306e\u52b9\u679c\u3092\u540c\u6642\u306b\u4ed8\u4e0e\u3059\u308b\u3002" }), - Object.freeze({ id: "slave_zero_satisfaction", title: "\u96f6\u4f53\u6e80\u8db3", description: "\u305a\u3093\u3061\u3069\u308c\u30441\u500b\u4f53\u306b\u30f3\u30a4\u30c6\u30ed\u30d7\u3068\u77ee\u5c0f\u85ac\u306e\u52b9\u679c\u3092\u540c\u6642\u306b\u4ed8\u4e0e\u3059\u308b\u3002" }), - Object.freeze({ id: "town_doctor_50", title: "\u753a\u306e\u304a\u533b\u8005\u3055\u3093", description: "\u6551\u6025\u85ac\u3092\u7d2f\u8a0850\u56de\u4f7f\u7528\u3057\u3001\u4f53\u529b\u3092\u56de\u5fa9\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "poke_plushie_fling", title: "\u5272\u3068\u3075\u308f\u3075\u308f", description: "\u306c\u3044\u3050\u308b\u307f\u3092\u300c\u3064\u3064\u304f\u300d\u3067\u5f3e\u304d\u98db\u3070\u3059\u3002" }), - Object.freeze({ id: "chaos_seeker_666_fights", title: "\u6df7\u6c8c\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u3051\u3093\u304b\u9905\u306e\u5f71\u97ff\u4e0b\u3067\u55a7\u5629\u3092\u7d2f\u8a08666\u56de\u767a\u751f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "clean_freak_robot_only", title: "\u6f54\u7656\u75c7", description: "\u30ed\u30dc\u30c3\u30c8\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u5168\u9805\u76ee\u306b\u30c1\u30a7\u30c3\u30af\u3092\u5165\u308c\u3066\u7a3c\u50cd\u3055\u305b\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u4e0a\u306b\u30ed\u30dc\u30c3\u30c8\u6383\u9664\u6a5f\u4ee5\u5916\u3092\u5b58\u5728\u3055\u305b\u306a\u3044\u3002" }), - Object.freeze({ id: "true_tarinai_observer", title: "\u771f\u30fb\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "\u3053\u308c\u4ee5\u5916\u306e\u5168\u3066\u306e\u5b9f\u7e3e\u3092\u9054\u6210\u3059\u308b\u3002" }), - Object.freeze({ id: "sniper_333_shots", title: "\u72d9\u6483\u624b", description: "\u7d2f\u8a08333\u767a\u6483\u3064\u3002" }), - Object.freeze({ id: "megalopolis", title: "\u30e1\u30ac\u30ed\u30dd\u30ea\u30b9", description: "\u8907\u88fd\u6a5f\u309210\u500b\u4ee5\u4e0a\u3001\u5e72\u8349\u5bdd\u5e8a\u30fb\u5de3\u7bb1\u30fb\u571f\u7ba1\u3092\u5408\u8a0815\u500b\u4ee5\u4e0a\u8a2d\u7f6e\u3057\u3001\u305f\u308a\u306a\u3044\u3092100\u4f53\u4ee5\u4e0a\u751f\u5b58\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "fertility_seeker_721_love_births", title: "\u8c4a\u7a63\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u3078\u3053\u9905\u306e\u5f71\u97ff\u4e0b\u3067\u7e41\u6b96\u3092721\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "low_fps_single_digit", title: "\u30b9\u30da\u30c3\u30af\u304c\u305f\u308a\u306a\u3044", description: "fps\u30921\u6841\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "continuous_play_24_hours", title: "\u5bdd\u308d", description: "24\u6642\u9593\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), - Object.freeze({ id: "sandbox_five_toilets", title: "\u7802\u5834", description: "\u30c8\u30a4\u30ec\u306e\u7802\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u306b5\u500b\u8a2d\u7f6e\u3059\u308b\u3002" }), - Object.freeze({ id: "park_ground_changed", title: "\u305d\u3053\u516c\u5171\u7a7a\u9593\u3060\u3088\uff1f", description: "\u516c\u5712\u306e\u5730\u9762\u3092\u52dd\u624b\u306b\u5909\u66f4\u3059\u308b\u3002" }), - Object.freeze({ id: "ground_change_4_in_1_second", title: "\u7834\u683c\u306e\u5de5\u4e8b\u8cbb\u7528", description: "\u5730\u9762\u3092\u5909\u3048\u3059\u304e\u3066\u7834\u7523\u3059\u308b\u3002" }), - Object.freeze({ id: "ant_nest_without_tarinai", title: "\u30a2\u30ea\u89b3\u5bdf", description: "\u30a2\u30ea\u306e\u5de3\u304c\u3042\u308b\u304c\u3001\u305f\u308a\u306a\u3044\u304c\u3044\u306a\u3044\u3002" }), - Object.freeze({ id: "sticky_bomb_15_passes", title: "\u547d\u306e\u30d0\u30c8\u30f3", description: "\u7c98\u7740\u30dc\u30e0\u3092\u7206\u767a\u307e\u3067\u306b15\u56de\u4ee5\u4e0a\u30d1\u30b9\u3059\u308b\u3002" }), - Object.freeze({ id: "daily_play_7_days", title: "\u6bce\u65e5\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "7\u65e5\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), - Object.freeze({ id: "wire_shock_7_tarinai", title: "\u62b5\u6297\u5668\u3058\u3083\u306a\u3044", description: "\u96fb\u6d41\u304c\u6d41\u308c\u3066\u3044\u308b1\u672c\u306e\u96fb\u7dda\u30677\u4f53\u306e\u305f\u308a\u306a\u3044\u3092\u611f\u96fb\u3055\u305b\u308b\u3002" }), - Object.freeze({ id: "information_industry", title: "\u60c5\u5831\u5316\u7523\u696d", description: "\u57fa\u76e4\u540c\u58eb\u3092\u96fb\u7dda\u985e\u3067\u3064\u306a\u304e\u3001\u4fe1\u53f7\u3092\u6d41\u3059\u3002" }), - Object.freeze({ id: "strength_in_numbers", title: "\u6570\u3053\u305d\u529b", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u305f\u308a\u306a\u3044\u304c100\u4f53\u306b\u5230\u9054\u3059\u308b\u3002" }), - Object.freeze({ id: "elite_few", title: "\u5c11\u6570\u7cbe\u92ed", description: "\u305f\u308a\u306a\u3044\u304c25\u4f53\u4ee5\u4e0b\u306e\u72b6\u614b\u30925\u65e5\u9593\u7dad\u6301\u3059\u308b\u3002" }), - Object.freeze({ id: "zunchi_overflow", title: "\u8e29\u5c4e\u611f", description: "\u305f\u308a\u306a\u3044\u304c20\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3067\u3001\u305a\u3093\u3061\u306e\u6570\u304c\u305f\u308a\u306a\u3044\u306e\u6570\u3092\u4e0a\u56de\u308b\u3002" }), - Object.freeze({ id: "comfortable_beds", title: "\u5feb\u9069\u306a\u5bdd\u5e8a", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u5168\u3066\u306e\u305f\u308a\u306a\u3044\u306b\u884c\u304d\u6e21\u308b\u6570\u306e\u5bdd\u5e8a\u3092\u7528\u610f\u3059\u308b\u3002" }), - Object.freeze({ id: "stone_pillow", title: "\u77f3\u306b\u6795\u3059", description: "\u305f\u308a\u306a\u3044\u304c30\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3067\u3001\u5bdd\u5e8a\u5145\u8db3\u7387\u30920%\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "across_seasons", title: "\u5b63\u7bc0\u3092\u8d8a\u3048\u3066", description: "\u6625\u590f\u79cb\u51ac\u306e\u56db\u5b63\u3092\u4e00\u5de1\u3059\u308b\u3002" }), - Object.freeze({ id: "favorite_one", title: "\u63a8\u3057", description: "\u3042\u308b\u500b\u4f53\u3092\u304a\u6c17\u306b\u5165\u308a\u306b\u767b\u9332\u3059\u308b\u3002" }), - Object.freeze({ id: "statistician", title: "\u7d71\u8a08\u5b66\u8005", description: "\u7d71\u8a08\u306e\u500b\u4f53\u6570\u30fb\u6b32\u6c42\u30fb\u74b0\u5883\u30fb\u30b0\u30e9\u30d5\u8868\u793a\u5207\u66ff\u30dc\u30bf\u30f3\u3092\u7d2f\u8a0830\u56de\u62bc\u3059\u3002" }), - Object.freeze({ id: "well_informed", title: "\u60c5\u5831\u901a", description: "\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3059\u308b\u3002" }), - Object.freeze({ id: "lively_making", title: "\u8cd1\u308f\u3044\u3065\u304f\u308a", description: "\u300c\u305f\u308a\u306a\u3044\u3092\u8ffd\u52a0\u300d\u3067100\u4f53\u8ffd\u52a0\u3059\u308b\u3002" }), - Object.freeze({ id: "memento_mori", title: "\u591a\u6570\u306e\u6b7b\u306f\u7d71\u8a08\u4e0a\u306e\u6570\u5b57\u3067\u3057\u304b\u306a\u3044", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }), - Object.freeze({ id: "mad_scientist", title: "\u30de\u30c3\u30c9\u30b5\u30a4\u30a8\u30f3\u30c6\u30a3\u30b9\u30c8", description: "\u7570\u306a\u308b10\u5339\u306e\u305f\u308a\u306a\u3044\u306b\u602a\u3057\u3044\u85ac\u3092\u6295\u4e0e\u3059\u308b\u3002" }), - ]); + const DEFINITIONS = Object.freeze(ACHIEVEMENT_CATALOG.definitions.map(definition => Object.freeze({ ...definition }))); + const PRE_UNLOCK_DESCRIPTION_IDS = new Set(DEFINITIONS.filter(definition => definition.showLockedDescription).map(definition => definition.id)); + const ACHIEVEMENT_CATEGORIES = Object.freeze(ACHIEVEMENT_CATALOG.categories.map(category => Object.freeze({ + id: String(category.id || ""), + title: String(category.title || ""), + ids: Object.freeze(DEFINITIONS.filter(definition => definition.category === category.id).map(definition => definition.id)), + }))); const definitionById = new Map(DEFINITIONS.map(def => [def.id, def])); const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [ category.id, @@ -235,8 +106,25 @@ pauseButton: document.getElementById("pauseBtn"), }; - let state = loadState(); - let shared = { connected: false, loading: false, totalPlayers: 0, achievements: {} }; + function createPersistenceActorId() { + if (global.crypto?.randomUUID) return global.crypto.randomUUID(); + return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; + } + + const persistenceActorId = createPersistenceActorId(); + let persistenceGeneration = 0; + let persistenceRevision = 0; + let persistenceHydrated = false; + let persistenceDirty = false; + let durableWriteQueue = Promise.resolve(); + let persistenceReadyPromise = Promise.resolve(); + let persistenceChannel = null; + const initialPersistence = loadStateRecord(); + const initialPersistenceBaseline = normalizeState(initialPersistence.state); + let state = initialPersistence.state; + persistenceGeneration = initialPersistence.generation; + persistenceRevision = initialPersistence.revision; + let shared = { connected: false, loading: false, totalPlayers: 0, achievements: {}, statisticsTrust: "client-reported" }; let requestSerial = 0; let runtimeByWorld = new WeakMap(); let dangerFightDeathsByWorld = new WeakMap(); @@ -246,70 +134,734 @@ let toastActive = false; let emptyClickTimes = []; let groundChangeTimes = []; - let continuousPlayStartedAt = Date.now(); - let continuousPlayLastHeartbeatAt = continuousPlayStartedAt; + let continuousPlayAccumulatedMs = 0; + let continuousPlayLastHeartbeatAt = Date.now(); + let continuousPlayActive = !document.hidden; let lastListSignature = ""; let scheduledSyncTimer = 0; let scheduledProgressSaveTimer = 0; + let progressMutationRevision = 0; + let progressSyncedRevision = 0; - function blankState() { - return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, directFeedCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } }; + function blankProgressLedger() { + return { + base: Object.fromEntries(ADDITIVE_PROGRESS_KEYS.map(key => [key, 0])), + shards: Object.fromEntries(ADDITIVE_PROGRESS_KEYS.map(key => [key, {}])), + }; } - function loadState() { + function blankState() { + return { + unlocked: {}, + pending: [], + resetPending: false, + debugUnlocked: [], + serverIdentity: null, + progress: { linkTypes: [], signalActivated: false, medicineTypes: [], dailyPlayStreak: 0, dailyPlayLastDay: 0, mysteryDrugTarinaiIds: [] }, + _progressLedger: blankProgressLedger(), + }; + } + + function ledgerCounterValue(ledgerInput, key) { + if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; + const cap = ADDITIVE_PROGRESS_CAPS[key]; + const ledger = ledgerInput && typeof ledgerInput === "object" ? ledgerInput : {}; + const base = Math.min(cap, Math.max(0, Math.floor(Number(ledger.base?.[key] || 0) || 0))); + const shards = ledger.shards?.[key] && typeof ledger.shards[key] === "object" ? ledger.shards[key] : {}; + return Math.min(cap, base + Object.values(shards).reduce((sum, value) => sum + Math.max(0, Math.floor(Number(value || 0) || 0)), 0)); + } + + function sanitizeProgressLedger(parsed, legacyProgress = {}) { + const source = parsed?._progressLedger && typeof parsed._progressLedger === "object" ? parsed._progressLedger : {}; + const baseSource = source.base && typeof source.base === "object" ? source.base : {}; + const shardsSource = source.shards && typeof source.shards === "object" ? source.shards : {}; + const ledger = blankProgressLedger(); + for (const key of ADDITIVE_PROGRESS_KEYS) { + const cap = ADDITIVE_PROGRESS_CAPS[key]; + let base = Math.min(cap, Math.max(0, Math.floor(Number(baseSource[key] || 0) || 0))); + const shards = {}; + const rawShards = shardsSource[key] && typeof shardsSource[key] === "object" ? shardsSource[key] : {}; + for (const [actorId, rawValue] of Object.entries(rawShards)) { + const id = String(actorId || "").slice(0, 160); + const value = Math.min(cap, Math.max(0, Math.floor(Number(rawValue || 0) || 0))); + if (id && value > 0) shards[id] = value; + } + let total = Math.min(cap, base + Object.values(shards).reduce((sum, value) => sum + value, 0)); + const legacyFloor = Math.min(cap, Math.max(0, Math.floor(Number(legacyProgress[key] || 0) || 0))); + if (legacyFloor > total) { + base = Math.min(cap, base + (legacyFloor - total)); + total = legacyFloor; + } + if (total >= cap) { + base = cap; + for (const actorId of Object.keys(shards)) delete shards[actorId]; + } + ledger.base[key] = base; + ledger.shards[key] = shards; + } + return ledger; + } + + function normalizeServerIdentity(value) { + if (!value || typeof value !== "object") return null; + const id = String(value.playerId || "").toLowerCase(); + const secret = String(value.playerSecret || "").toLowerCase(); + if (!PLAYER_ID_PATTERN.test(id) || !PLAYER_SECRET_PATTERN.test(secret)) return null; + return { playerId: id, playerSecret: secret }; + } + + function normalizeState(parsed) { + if (!parsed || typeof parsed !== "object") return blankState(); + const unlockedSource = parsed.unlocked && typeof parsed.unlocked === "object" ? parsed.unlocked : {}; + const unlocked = {}; + for (const [id, timestamp] of Object.entries(unlockedSource)) { + if (definitionById.has(id) && Number(timestamp) > 0) unlocked[id] = Number(timestamp); + } + const debugUnlockedSource = Array.isArray(parsed.debugUnlocked) ? parsed.debugUnlocked : []; + const debugUnlocked = [...new Set(debugUnlockedSource.filter(id => definitionById.has(id)))]; + if (!DEBUG_MODE) { + for (const id of debugUnlocked) delete unlocked[id]; + } + // v6 and older persisted a redundant synced list. It is read once only to + // avoid re-queuing already acknowledged legacy unlocks, then discarded. + const legacySynced = new Set((Array.isArray(parsed.synced) ? parsed.synced : []).filter(id => definitionById.has(id))); + const pendingSource = Array.isArray(parsed.pending) ? parsed.pending : []; + const pending = pendingSource.filter(id => definitionById.has(id) && unlocked[id] && !debugUnlocked.includes(id) && !legacySynced.has(id)); + const progressSource = parsed.progress && typeof parsed.progress === "object" ? parsed.progress : {}; + const progress = { + linkTypes: [...new Set((Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []).filter(type => LINK_PROGRESS_TYPES.includes(type)))], + signalActivated: Boolean(progressSource.signalActivated), + medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))], + dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))), + dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)), + mysteryDrugTarinaiIds: [...new Set((Array.isArray(progressSource.mysteryDrugTarinaiIds) ? progressSource.mysteryDrugTarinaiIds : []).map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET), + }; + const progressLedger = sanitizeProgressLedger(parsed, progressSource); + const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); + if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { + delete unlocked[COMPLETIONIST_ID]; + const pendingIndex = pending.indexOf(COMPLETIONIST_ID); + if (pendingIndex >= 0) pending.splice(pendingIndex, 1); + } + return { unlocked, pending: [...new Set(pending)], resetPending: Boolean(parsed.resetPending), debugUnlocked: DEBUG_MODE ? debugUnlocked : [], serverIdentity: normalizeServerIdentity(parsed.serverIdentity), progress, _progressLedger: progressLedger }; + } + + function decodePersistenceEnvelope(raw) { try { - const parsed = JSON.parse(global.localStorage?.getItem(STORAGE_KEY) || "null"); - if (!parsed || typeof parsed !== "object") return blankState(); - const unlockedSource = parsed.unlocked && typeof parsed.unlocked === "object" ? parsed.unlocked : {}; - const unlocked = {}; - for (const [id, timestamp] of Object.entries(unlockedSource)) { - if (definitionById.has(id) && Number(timestamp) > 0) unlocked[id] = Number(timestamp); + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!parsed || typeof parsed !== "object") return null; + if (parsed.format === PERSISTENCE_FORMAT && parsed.state && typeof parsed.state === "object") { + return { + generation: Math.max(0, Math.floor(Number(parsed.generation || 0) || 0)), + revision: Math.max(0, Math.floor(Number(parsed.revision || 0) || 0)), + updatedAt: Math.max(0, Number(parsed.updatedAt || 0) || 0), + migrationComplete: Boolean(parsed.migrationComplete), + state: normalizeState(parsed.state), + }; } - const debugUnlockedSource = Array.isArray(parsed.debugUnlocked) ? parsed.debugUnlocked : []; - const debugUnlocked = [...new Set(debugUnlockedSource.filter(id => definitionById.has(id)))]; - if (!DEBUG_MODE) { - for (const id of debugUnlocked) delete unlocked[id]; - } - const pendingSource = Array.isArray(parsed.pending) ? parsed.pending : []; - const pending = pendingSource.filter(id => definitionById.has(id) && !debugUnlocked.includes(id)); - const progressSource = parsed.progress && typeof parsed.progress === "object" ? parsed.progress : {}; - const progress = { - placementCount: Math.max(0, Math.floor(Number(progressSource.placementCount || 0) || 0)), - linkTypes: [...new Set((Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []).filter(type => ["rope", "rod", "spring"].includes(type)))], - signalActivated: Boolean(progressSource.signalActivated), - antKills: Math.max(0, Math.floor(Number(progressSource.antKills || 0) || 0)), - birthCount: Math.max(0, Math.floor(Number(progressSource.birthCount || 0) || 0)), - quickDeleteCount: Math.max(0, Math.floor(Number(progressSource.quickDeleteCount || 0) || 0)), - undoCount: Math.max(0, Math.floor(Number(progressSource.undoCount || 0) || 0)), - redoCount: Math.max(0, Math.floor(Number(progressSource.redoCount || 0) || 0)), - directFeedCount: Math.min(33, Math.max(0, Math.floor(Number(progressSource.directFeedCount || 0) || 0))), - medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))], - firstAidHeals: Math.max(0, Math.floor(Number(progressSource.firstAidHeals || 0) || 0)), - fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)), - shotCount: Math.max(0, Math.floor(Number(progressSource.shotCount || 0) || 0)), - loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 0) || 0)), - dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))), - dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)), - statsButtonPressCount: Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(progressSource.statsButtonPressCount || 0) || 0))), - totalDeathCount: Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(progressSource.totalDeathCount || 0) || 0))), - manualTarinaiAddedCount: Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(progressSource.manualTarinaiAddedCount || 0) || 0))), - mysteryDrugTarinaiIds: [...new Set((Array.isArray(progressSource.mysteryDrugTarinaiIds) ? progressSource.mysteryDrugTarinaiIds : []).map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET), - }; - const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); - if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { - delete unlocked[COMPLETIONIST_ID]; - const pendingIndex = pending.indexOf(COMPLETIONIST_ID); - if (pendingIndex >= 0) pending.splice(pendingIndex, 1); - } - return { unlocked, pending: [...new Set(pending)], resetPending: Boolean(parsed.resetPending), debugUnlocked: DEBUG_MODE ? debugUnlocked : [], progress }; + return { generation: 0, revision: 0, updatedAt: 0, migrationComplete: false, state: normalizeState(parsed) }; } catch (_) { - return blankState(); + return null; } } + function mergeStateData(targetInput, sourceInput) { + const target = normalizeState(targetInput); + const source = normalizeState(sourceInput); + for (const [id, timestamp] of Object.entries(source.unlocked)) { + const current = Number(target.unlocked[id] || 0) || 0; + target.unlocked[id] = current > 0 ? Math.min(current, timestamp) : timestamp; + } + target.pending = [...new Set([...target.pending, ...source.pending])] + .filter(id => definitionById.has(id) && target.unlocked[id] && !target.debugUnlocked.includes(id)); + if (DEBUG_MODE) target.debugUnlocked = [...new Set([...target.debugUnlocked, ...source.debugUnlocked])]; + if (!target.serverIdentity && source.serverIdentity) target.serverIdentity = { ...source.serverIdentity }; + for (const key of ADDITIVE_PROGRESS_KEYS) { + target._progressLedger.base[key] = Math.max( + Number(target._progressLedger.base[key] || 0) || 0, + Number(source._progressLedger.base[key] || 0) || 0, + ); + const targetShards = target._progressLedger.shards[key] || (target._progressLedger.shards[key] = {}); + const sourceShards = source._progressLedger.shards[key] || {}; + for (const [actorId, value] of Object.entries(sourceShards)) { + targetShards[actorId] = Math.max(Number(targetShards[actorId] || 0) || 0, Number(value || 0) || 0); + } + } + // Daily streak and its last-day marker are one logical record. Merging the + // two numbers independently can pair an old high streak with a new date. + const targetDay = Number(target.progress.dailyPlayLastDay || 0) || 0; + const sourceDay = Number(source.progress.dailyPlayLastDay || 0) || 0; + if (sourceDay > targetDay) { + target.progress.dailyPlayLastDay = sourceDay; + target.progress.dailyPlayStreak = Number(source.progress.dailyPlayStreak || 0) || 0; + } else if (sourceDay === targetDay) { + target.progress.dailyPlayStreak = Math.max(Number(target.progress.dailyPlayStreak || 0) || 0, Number(source.progress.dailyPlayStreak || 0) || 0); + } + target.progress.signalActivated = Boolean(target.progress.signalActivated || source.progress.signalActivated); + target.progress.linkTypes = [...new Set([...(target.progress.linkTypes || []), ...(source.progress.linkTypes || [])])]; + target.progress.medicineTypes = [...new Set([...(target.progress.medicineTypes || []), ...(source.progress.medicineTypes || [])])]; + target.progress.mysteryDrugTarinaiIds = [...new Set([...(target.progress.mysteryDrugTarinaiIds || []), ...(source.progress.mysteryDrugTarinaiIds || [])])].slice(0, MAD_SCIENTIST_TARGET); + return normalizeState(target); + } + + function growOnlyStateDelta(baseInput, currentInput) { + const base = normalizeState(baseInput); + const current = normalizeState(currentInput); + const delta = blankState(); + for (const [id, timestamp] of Object.entries(current.unlocked)) { + const previous = Number(base.unlocked[id] || 0) || 0; + if (!previous || timestamp < previous) delta.unlocked[id] = timestamp; + } + delta.pending = current.pending.filter(id => delta.unlocked[id]); + if (DEBUG_MODE) delta.debugUnlocked = current.debugUnlocked.filter(id => !base.debugUnlocked.includes(id)); + if (current.serverIdentity && (!base.serverIdentity || current.serverIdentity.playerId !== base.serverIdentity.playerId || current.serverIdentity.playerSecret !== base.serverIdentity.playerSecret)) { + delta.serverIdentity = { ...current.serverIdentity }; + } + for (const key of ADDITIVE_PROGRESS_KEYS) { + const currentBase = Number(current._progressLedger.base[key] || 0) || 0; + const baselineBase = Number(base._progressLedger.base[key] || 0) || 0; + if (currentBase > baselineBase) delta._progressLedger.base[key] = currentBase; + const currentShards = current._progressLedger.shards[key] || {}; + const baselineShards = base._progressLedger.shards[key] || {}; + const deltaShards = delta._progressLedger.shards[key] + || (delta._progressLedger.shards[key] = {}); + for (const [actorId, value] of Object.entries(currentShards)) { + if ((Number(value) || 0) > (Number(baselineShards[actorId]) || 0)) { + deltaShards[actorId] = Number(value) || 0; + } + } + } + const currentDay = Number(current.progress.dailyPlayLastDay || 0) || 0; + const baseDay = Number(base.progress.dailyPlayLastDay || 0) || 0; + const currentStreak = Number(current.progress.dailyPlayStreak || 0) || 0; + const baseStreak = Number(base.progress.dailyPlayStreak || 0) || 0; + if (currentDay !== baseDay) { + delta.progress.dailyPlayLastDay = currentDay; + delta.progress.dailyPlayStreak = currentStreak; + } else if (currentStreak > baseStreak) { + delta.progress.dailyPlayStreak = currentStreak; + } + delta.progress.signalActivated = Boolean(current.progress.signalActivated && !base.progress.signalActivated); + delta.progress.linkTypes = current.progress.linkTypes.filter(value => !base.progress.linkTypes.includes(value)); + delta.progress.medicineTypes = current.progress.medicineTypes.filter(value => !base.progress.medicineTypes.includes(value)); + delta.progress.mysteryDrugTarinaiIds = current.progress.mysteryDrugTarinaiIds.filter(value => !base.progress.mysteryDrugTarinaiIds.includes(value)); + return normalizeState(delta); + } + + function readStorageCandidate(storage, key) { + try { return decodePersistenceEnvelope(storage?.getItem?.(key) || null); } + catch (_) { return null; } + } + + function webStorageCandidates() { + return [ + readStorageCandidate(global.localStorage, STORAGE_KEY), + readStorageCandidate(global.localStorage, STORAGE_BACKUP_KEY), + readStorageCandidate(global.sessionStorage, STORAGE_SESSION_KEY), + ].filter(Boolean); + } + + function legacyWebStorageCandidates() { + return [ + readStorageCandidate(global.localStorage, PREVIOUS_STORAGE_KEY), + readStorageCandidate(global.localStorage, PREVIOUS_STORAGE_BACKUP_KEY), + readStorageCandidate(global.sessionStorage, PREVIOUS_STORAGE_SESSION_KEY), + readStorageCandidate(global.localStorage, OLDER_STORAGE_KEY), + readStorageCandidate(global.localStorage, OLDER_STORAGE_BACKUP_KEY), + readStorageCandidate(global.sessionStorage, OLDER_STORAGE_SESSION_KEY), + readStorageCandidate(global.localStorage, LEGACY_STORAGE_KEY), + readStorageCandidate(global.localStorage, LEGACY_STORAGE_BACKUP_KEY), + readStorageCandidate(global.sessionStorage, LEGACY_STORAGE_SESSION_KEY), + readStorageCandidate(global.localStorage, ANCIENT_STORAGE_KEY), + readStorageCandidate(global.localStorage, ANCIENT_STORAGE_BACKUP_KEY), + readStorageCandidate(global.sessionStorage, ANCIENT_STORAGE_SESSION_KEY), + ].filter(Boolean); + } + + function decodePreHydrationJournal(raw) { + try { + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!parsed || parsed.format !== PREHYDRATION_JOURNAL_FORMAT || !parsed.delta || typeof parsed.delta !== "object") return null; + return { + format: PREHYDRATION_JOURNAL_FORMAT, + actorId: String(parsed.actorId || "").slice(0, 160), + baseGeneration: Math.max(0, Math.floor(Number(parsed.baseGeneration || 0) || 0)), + updatedAt: Math.max(0, Number(parsed.updatedAt || 0) || 0), + delta: normalizeState(parsed.delta), + }; + } catch (_) { + return null; + } + } + + function storageJournalCandidates(storage) { + const journals = []; + try { + const length = Math.max(0, Number(storage?.length || 0) || 0); + for (let index = 0; index < length; index += 1) { + const key = String(storage?.key?.(index) || ""); + if (!key.startsWith(PREHYDRATION_JOURNAL_PREFIX)) continue; + const journal = decodePreHydrationJournal(storage?.getItem?.(key)); + if (journal) journals.push(journal); + } + } catch (_) {} + return journals; + } + + function webStorageJournals() { + return [...storageJournalCandidates(global.localStorage), ...storageJournalCandidates(global.sessionStorage)]; + } + + function currentPreHydrationJournal() { + return { + format: PREHYDRATION_JOURNAL_FORMAT, + actorId: persistenceActorId, + baseGeneration: persistenceGeneration, + updatedAt: Date.now(), + delta: growOnlyStateDelta(initialPersistenceBaseline, state), + }; + } + + function writePreHydrationJournalToWebStorage(journalInput) { + const journal = decodePreHydrationJournal(journalInput); + if (!journal) return false; + let serialized = ""; + try { serialized = JSON.stringify(journal); } catch (_) { return false; } + const key = `${PREHYDRATION_JOURNAL_PREFIX}${persistenceActorId}`; + let stored = false; + try { global.localStorage?.setItem?.(key, serialized); stored = true; } catch (_) {} + try { global.sessionStorage?.setItem?.(key, serialized); stored = true; } catch (_) {} + return stored; + } + + function removeWebStorageJournals(maxGeneration = persistenceGeneration) { + for (const storage of [global.localStorage, global.sessionStorage]) { + try { + const keys = []; + const length = Math.max(0, Number(storage?.length || 0) || 0); + for (let index = 0; index < length; index += 1) { + const key = String(storage?.key?.(index) || ""); + if (key.startsWith(PREHYDRATION_JOURNAL_PREFIX)) keys.push(key); + } + for (const key of keys) { + const journal = decodePreHydrationJournal(storage?.getItem?.(key)); + if (!journal || journal.baseGeneration <= maxGeneration) storage?.removeItem?.(key); + } + } catch (_) {} + } + } + + function persistenceStateSignature(generation, stateInput) { + try { return `${Math.max(0, Number(generation) || 0)}:${JSON.stringify(normalizeState(stateInput))}`; } + catch (_) { return `${Math.max(0, Number(generation) || 0)}:`; } + } + + function normalizePersistenceCandidate(candidate) { + if (candidate && typeof candidate === "object" && candidate.state && typeof candidate.state === "object" && Number.isFinite(Number(candidate.generation))) { + return { + generation: Math.max(0, Math.floor(Number(candidate.generation || 0) || 0)), + revision: Math.max(0, Math.floor(Number(candidate.revision || 0) || 0)), + updatedAt: Math.max(0, Number(candidate.updatedAt || 0) || 0), + migrationComplete: Boolean(candidate.migrationComplete), + state: normalizeState(candidate.state), + }; + } + return decodePersistenceEnvelope(candidate); + } + + function mergePersistenceEnvelopes(candidatesInput, options = {}) { + const candidates = (Array.isArray(candidatesInput) ? candidatesInput : []) + .map(candidate => normalizePersistenceCandidate(candidate)) + .filter(Boolean); + if (!candidates.length) { + return { + format: PERSISTENCE_FORMAT, + generation: 0, + revision: options.incrementRevision ? 1 : 0, + updatedAt: Date.now(), + migrationComplete: false, + state: blankState(), + }; + } + const generation = Math.max(...candidates.map(candidate => candidate.generation)); + const active = candidates + .filter(candidate => candidate.generation === generation) + .sort((a, b) => (a.revision - b.revision) || (a.updatedAt - b.updatedAt)); + let merged = blankState(); + for (const candidate of active) merged = mergeStateData(merged, candidate.state); + const latest = active[active.length - 1]; + merged.resetPending = Boolean(latest?.state?.resetPending); + const latestIdentity = [...active].reverse().map(candidate => normalizeServerIdentity(candidate.state?.serverIdentity)).find(Boolean); + if (latestIdentity) merged.serverIdentity = { ...latestIdentity }; + const maxRevision = Math.max(0, ...active.map(candidate => candidate.revision)); + return { + format: PERSISTENCE_FORMAT, + generation, + revision: maxRevision + (options.incrementRevision ? 1 : 0), + updatedAt: Date.now(), + migrationComplete: active.some(candidate => candidate.migrationComplete), + state: normalizeState(merged), + }; + } + + function loadStateRecord() { + const currentCandidates = webStorageCandidates(); + const currentGeneration = currentCandidates.length + ? Math.max(...currentCandidates.map(candidate => candidate.generation)) + : 0; + // v6 is written only by this build. Import older namespaces once, before a + // completed v6 envelope exists. This still repairs the v39.17.7 failure + // where an empty v5 envelope hid v4/v2 data, without reading old namespaces + // forever and allowing an old open tab to contaminate current storage. + const migrationComplete = currentCandidates.some(candidate => candidate.migrationComplete); + const legacyCandidates = currentGeneration === 0 && !migrationComplete ? legacyWebStorageCandidates() : []; + const candidates = [...currentCandidates, ...legacyCandidates]; + const source = currentCandidates.length && legacyCandidates.length + ? "mixed" + : (currentCandidates.length ? "current" : (legacyCandidates.length ? "legacy" : "empty")); + const merged = mergePersistenceEnvelopes(candidates); + return { + generation: merged.generation, + revision: merged.revision, + updatedAt: merged.updatedAt, + migrationComplete: merged.migrationComplete, + state: merged.state, + source, + }; + } + + function persistenceEnvelope() { + return { + format: PERSISTENCE_FORMAT, + generation: persistenceGeneration, + revision: persistenceRevision, + updatedAt: Date.now(), + migrationComplete: true, + state: normalizeState(state), + }; + } + + function adoptPersistenceEnvelope(envelopeInput) { + const envelope = normalizePersistenceCandidate(envelopeInput); + if (!envelope || envelope.generation < persistenceGeneration) return false; + const before = persistenceStateSignature(persistenceGeneration, state); + if (envelope.generation > persistenceGeneration) { + persistenceGeneration = envelope.generation; + persistenceRevision = envelope.revision; + state = normalizeState(envelope.state); + } else { + state = mergeStateData(state, envelope.state); + if (envelope.revision >= persistenceRevision) { + state.resetPending = Boolean(envelope.state.resetPending); + const newerIdentity = normalizeServerIdentity(envelope.state.serverIdentity); + if (newerIdentity) state.serverIdentity = { ...newerIdentity }; + } + persistenceRevision = Math.max(persistenceRevision, envelope.revision); + } + return before !== persistenceStateSignature(persistenceGeneration, state); + } + + function writeEnvelopeToWebStorage(envelopeInput) { + const envelope = normalizePersistenceCandidate(envelopeInput); + if (!envelope) return false; + let serialized = ""; + try { + serialized = JSON.stringify({ + format: PERSISTENCE_FORMAT, + generation: envelope.generation, + revision: envelope.revision, + updatedAt: envelope.updatedAt || Date.now(), + migrationComplete: Boolean(envelope.migrationComplete), + state: normalizeState(envelope.state), + }); + } catch (_) { + return false; + } + let stored = false; + try { global.localStorage?.setItem?.(STORAGE_BACKUP_KEY, serialized); stored = true; } catch (_) {} + try { global.localStorage?.setItem?.(STORAGE_KEY, serialized); stored = true; } catch (_) {} + try { global.sessionStorage?.setItem?.(STORAGE_SESSION_KEY, serialized); stored = true; } catch (_) {} + return stored; + } + + function broadcastPersistenceEnvelope(envelopeInput) { + const envelope = normalizePersistenceCandidate(envelopeInput); + if (!envelope) return; + try { persistenceChannel?.postMessage?.(envelope); } catch (_) {} + } + + function openPersistenceDatabase(databaseName = PERSISTENCE_DB_NAME) { + return new Promise((resolve, reject) => { + if (!global.indexedDB?.open) return reject(new Error("indexeddb unavailable")); + let request; + try { request = global.indexedDB.open(databaseName, 1); } + catch (error) { reject(error); return; } + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(PERSISTENCE_DB_STORE)) db.createObjectStore(PERSISTENCE_DB_STORE); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("indexeddb open failed")); + request.onblocked = () => reject(new Error("indexeddb blocked")); + }); + } + + async function readDurableEnvelope(databaseName = PERSISTENCE_DB_NAME) { + let db = null; + try { + db = await openPersistenceDatabase(databaseName); + return await new Promise((resolve, reject) => { + const tx = db.transaction(PERSISTENCE_DB_STORE, "readonly"); + const request = tx.objectStore(PERSISTENCE_DB_STORE).get(PERSISTENCE_DB_KEY); + request.onsuccess = () => resolve(decodePersistenceEnvelope(request.result)); + request.onerror = () => reject(request.error || new Error("indexeddb read failed")); + tx.onabort = () => reject(tx.error || new Error("indexeddb read aborted")); + }); + } catch (_) { + return null; + } finally { + try { db?.close?.(); } catch (_) {} + } + } + + async function readDurableJournals() { + let db = null; + try { + db = await openPersistenceDatabase(); + return await new Promise((resolve, reject) => { + const journals = []; + const tx = db.transaction(PERSISTENCE_DB_STORE, "readonly"); + const store = tx.objectStore(PERSISTENCE_DB_STORE); + const request = store.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + if (String(cursor.key || "").startsWith(PREHYDRATION_JOURNAL_DB_PREFIX)) { + const journal = decodePreHydrationJournal(cursor.value); + if (journal) journals.push(journal); + } + cursor.continue(); + }; + request.onerror = () => reject(request.error || new Error("indexeddb journal read failed")); + tx.oncomplete = () => resolve(journals); + tx.onerror = () => reject(tx.error || new Error("indexeddb journal transaction failed")); + tx.onabort = () => reject(tx.error || new Error("indexeddb journal read aborted")); + }); + } catch (_) { + return []; + } finally { + try { db?.close?.(); } catch (_) {} + } + } + + async function writeDurableJournal(journalInput) { + const journal = decodePreHydrationJournal(journalInput); + if (!journal) return false; + let db = null; + try { + db = await openPersistenceDatabase(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); + tx.objectStore(PERSISTENCE_DB_STORE).put(journal, `${PREHYDRATION_JOURNAL_DB_PREFIX}${journal.actorId || persistenceActorId}`); + tx.oncomplete = () => resolve(true); + tx.onerror = () => reject(tx.error || new Error("indexeddb journal write failed")); + tx.onabort = () => reject(tx.error || new Error("indexeddb journal write aborted")); + }); + } catch (_) { + return false; + } finally { + try { db?.close?.(); } catch (_) {} + } + } + + async function removeDurableJournals(maxGeneration = persistenceGeneration) { + let db = null; + try { + db = await openPersistenceDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); + const store = tx.objectStore(PERSISTENCE_DB_STORE); + const request = store.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + if (String(cursor.key || "").startsWith(PREHYDRATION_JOURNAL_DB_PREFIX)) { + const journal = decodePreHydrationJournal(cursor.value); + if (!journal || journal.baseGeneration <= maxGeneration) cursor.delete(); + } + cursor.continue(); + }; + request.onerror = () => reject(request.error || new Error("indexeddb journal cleanup failed")); + tx.oncomplete = () => resolve(true); + tx.onerror = () => reject(tx.error || new Error("indexeddb journal cleanup transaction failed")); + tx.onabort = () => reject(tx.error || new Error("indexeddb journal cleanup aborted")); + }); + } catch (_) { + return false; + } finally { + try { db?.close?.(); } catch (_) {} + } + return true; + } + + async function writeDurableEnvelope(envelopeInput) { + const incoming = normalizePersistenceCandidate(envelopeInput); + if (!incoming) return null; + let db = null; + try { + db = await openPersistenceDatabase(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); + const store = tx.objectStore(PERSISTENCE_DB_STORE); + const request = store.get(PERSISTENCE_DB_KEY); + let committed = incoming; + request.onsuccess = () => { + const existing = normalizePersistenceCandidate(request.result); + if (existing?.generation > incoming.generation) { + committed = existing; + } else if (existing?.generation === incoming.generation) { + committed = mergePersistenceEnvelopes([existing, incoming], { incrementRevision: true }); + } + if (existing?.generation > incoming.generation) return; + try { store.put(committed, PERSISTENCE_DB_KEY); } + catch (error) { reject(error); } + }; + request.onerror = () => reject(request.error || new Error("indexeddb read before write failed")); + tx.oncomplete = () => resolve(committed); + tx.onerror = () => reject(tx.error || new Error("indexeddb write failed")); + tx.onabort = () => reject(tx.error || new Error("indexeddb write aborted")); + }); + } catch (_) { + return null; + } finally { + try { db?.close?.(); } catch (_) {} + } + } + + function handleExternalPersistenceEnvelope(envelopeInput, options = {}) { + const envelope = normalizePersistenceCandidate(envelopeInput); + if (!envelope) return false; + const changed = adoptPersistenceEnvelope(envelope); + if (!changed) return false; + const merged = mergePersistenceEnvelopes([persistenceEnvelope(), envelope], { incrementRevision: true }); + adoptPersistenceEnvelope(merged); + writeEnvelopeToWebStorage(merged); + if (options.broadcast !== false) broadcastPersistenceEnvelope(merged); + durableWriteQueue = durableWriteQueue.then( + () => writeDurableEnvelope(merged), + () => writeDurableEnvelope(merged), + ).then(committed => { + if (!committed) return null; + const durableChanged = adoptPersistenceEnvelope(committed); + writeEnvelopeToWebStorage(committed); + if (durableChanged) { + broadcastPersistenceEnvelope(committed); + render({ forceList: true }); + } + return committed; + }); + render({ forceList: true }); + return true; + } + + function setupPersistenceCrossTabSync() { + try { + if (typeof global.BroadcastChannel === "function") { + persistenceChannel = new global.BroadcastChannel(PERSISTENCE_CHANNEL_NAME); + persistenceChannel.addEventListener?.("message", event => { + handleExternalPersistenceEnvelope(event?.data, { broadcast: true }); + }); + } + } catch (_) { + persistenceChannel = null; + } + global.addEventListener?.("storage", event => { + if (![STORAGE_KEY, STORAGE_BACKUP_KEY].includes(String(event?.key || ""))) return; + handleExternalPersistenceEnvelope(event?.newValue, { broadcast: true }); + }); + } + + function persistStateNow() { + persistenceDirty = false; + const current = persistenceEnvelope(); + const envelope = mergePersistenceEnvelopes([current, ...webStorageCandidates()], { incrementRevision: true }); + adoptPersistenceEnvelope(envelope); + const stored = writeEnvelopeToWebStorage(envelope); + broadcastPersistenceEnvelope(envelope); + durableWriteQueue = durableWriteQueue.then( + () => writeDurableEnvelope(envelope), + () => writeDurableEnvelope(envelope), + ).then(committed => { + if (!committed) return null; + const changed = adoptPersistenceEnvelope(committed); + writeEnvelopeToWebStorage(committed); + if (changed) { + broadcastPersistenceEnvelope(committed); + render({ forceList: true }); + } + return committed; + }); + return stored; + } + function saveState() { - try { global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(state)); } - catch (_) {} + persistenceDirty = true; + if (!persistenceHydrated) { + const journal = currentPreHydrationJournal(); + const stored = writePreHydrationJournalToWebStorage(journal); + durableWriteQueue = durableWriteQueue.then( + () => writeDurableJournal(journal), + () => writeDurableJournal(journal), + ); + return stored; + } + return persistStateNow(); + } + + async function hydrateDurableState() { + try { + const persistRequest = global.navigator?.storage?.persist?.(); + persistRequest?.catch?.(() => false); + } catch (_) {} + + const [currentDurable, previousDurable, olderDurable, legacyDurable, ancientDurable, durableJournals] = await Promise.all([ + readDurableEnvelope(PERSISTENCE_DB_NAME), + readDurableEnvelope(PREVIOUS_PERSISTENCE_DB_NAME), + readDurableEnvelope(OLDER_PERSISTENCE_DB_NAME), + readDurableEnvelope(LEGACY_PERSISTENCE_DB_NAME), + readDurableEnvelope(ANCIENT_PERSISTENCE_DB_NAME), + readDurableJournals(), + ]); + const currentCandidates = [...webStorageCandidates(), currentDurable].filter(Boolean); + const currentGeneration = currentCandidates.length + ? Math.max(...currentCandidates.map(candidate => candidate.generation)) + : 0; + const migrationComplete = currentCandidates.some(candidate => candidate.migrationComplete); + const legacyCandidates = currentGeneration === 0 && !migrationComplete + ? [...legacyWebStorageCandidates(), previousDurable, olderDurable, legacyDurable, ancientDurable].filter(Boolean) + : []; + const baseEnvelope = mergePersistenceEnvelopes([...currentCandidates, ...legacyCandidates]); + const preHydrationDelta = growOnlyStateDelta(initialPersistenceBaseline, state); + const journals = [...webStorageJournals(), ...durableJournals] + .filter(journal => journal.baseGeneration === baseEnvelope.generation); + + let mergedState = baseEnvelope.state; + if (initialPersistence.generation === baseEnvelope.generation) { + mergedState = mergeStateData(mergedState, preHydrationDelta); + } + for (const journal of journals) mergedState = mergeStateData(mergedState, journal.delta); + + persistenceGeneration = baseEnvelope.generation; + persistenceRevision = baseEnvelope.revision; + state = normalizeState(mergedState); + state.resetPending = Boolean(baseEnvelope.state.resetPending); + persistenceHydrated = true; + persistStateNow(); + await durableWriteQueue.catch(() => null); + removeWebStorageJournals(persistenceGeneration); + await removeDurableJournals(persistenceGeneration); + render({ forceList: true }); + return true; } function scheduleProgressSave(delay = 350) { @@ -320,6 +872,11 @@ }, Math.max(0, Number(delay) || 0)); } + function markProgressMutation() { + progressMutationRevision += 1; + if (!DEBUG_MODE && global.location?.protocol !== "file:") schedulePendingSync(); + } + function createPlayerId() { if (global.crypto?.randomUUID) return global.crypto.randomUUID(); const hex = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; @@ -329,19 +886,104 @@ }); } - function playerId() { + function createPlayerSecret() { + const bytes = new Uint8Array(32); + if (global.crypto?.getRandomValues) global.crypto.getRandomValues(bytes); + else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); + } + + function storageServerIdentity(storage) { try { - const stored = String(global.localStorage?.getItem(PLAYER_KEY) || ""); - if (PLAYER_ID_PATTERN.test(stored)) return stored; - const next = createPlayerId(); - global.localStorage?.setItem(PLAYER_KEY, next); - return next; + return normalizeServerIdentity({ + playerId: storage?.getItem?.(PLAYER_KEY), + playerSecret: storage?.getItem?.(PLAYER_SECRET_KEY), + }); } catch (_) { - if (!playerId.memory) playerId.memory = createPlayerId(); - return playerId.memory; + return null; } } + function legacyPlayerId() { + const candidates = []; + for (const storage of [global.localStorage, global.sessionStorage]) { + try { + candidates.push(storage?.getItem?.(LEGACY_PLAYER_KEY)); + candidates.push(storage?.getItem?.(PLAYER_KEY)); + } catch (_) {} + } + candidates.push(legacyPlayerId.memory); + const legacyId = candidates.map(value => String(value || "")).find(value => LEGACY_PLAYER_ID_PATTERN.test(value)) || ""; + if (!legacyId) return ""; + legacyPlayerId.memory = legacyId; + for (const storage of [global.localStorage, global.sessionStorage]) { + try { storage?.setItem?.(LEGACY_PLAYER_KEY, legacyId); } catch (_) {} + } + return legacyId; + } + + function clearLegacyPlayerId() { + legacyPlayerId.memory = ""; + for (const storage of [global.localStorage, global.sessionStorage]) { + try { storage?.removeItem?.(LEGACY_PLAYER_KEY); } catch (_) {} + } + } + + function currentServerIdentity() { + return storageServerIdentity(global.localStorage) + || storageServerIdentity(global.sessionStorage) + || normalizeServerIdentity(state?.serverIdentity) + || normalizeServerIdentity(currentServerIdentity.memory); + } + + function persistServerIdentity(identityInput, options = {}) { + const identity = normalizeServerIdentity(identityInput); + if (!identity) return false; + currentServerIdentity.memory = identity; + for (const storage of [global.localStorage, global.sessionStorage]) { + try { + storage?.setItem?.(PLAYER_KEY, identity.playerId); + storage?.setItem?.(PLAYER_SECRET_KEY, identity.playerSecret); + } catch (_) {} + } + const before = normalizeServerIdentity(state?.serverIdentity); + const changed = !before || before.playerId !== identity.playerId || before.playerSecret !== identity.playerSecret; + if (state && typeof state === "object") state.serverIdentity = { ...identity }; + if (changed && options.save !== false) saveState(); + return true; + } + + function ensureServerIdentity() { + // Capture the legacy bearer ID before the current UUID overwrites the old + // localStorage slot. It is retained until the server confirms the claim. + legacyPlayerId(); + const existing = currentServerIdentity(); + if (existing) { + persistServerIdentity(existing, { save: false }); + return existing; + } + const created = { playerId: createPlayerId(), playerSecret: createPlayerSecret() }; + persistServerIdentity(created); + return created; + } + + function playerId() { + return ensureServerIdentity().playerId; + } + + function playerSecret() { + return ensureServerIdentity().playerSecret; + } + + function storeServerCredentials(data) { + const stored = persistServerIdentity({ + playerId: String(data?.playerId || "").toLowerCase(), + playerSecret: String(data?.playerSecret || "").toLowerCase(), + }); + if (stored && data?.legacyClaimed === true) clearLegacyPlayerId(); + return stored; + } + function unlockedCount() { return DEFINITIONS.reduce((sum, def) => sum + (state.unlocked[def.id] ? 1 : 0), 0); } @@ -379,14 +1021,17 @@ } function sharedAchievementText(id) { - return `\u5168\u4f53\u9054\u6210\u7387 ${percentageText(id)}\uff08\u9054\u6210\u8005 ${achieverCountText(id)}\uff09`; + return `端末報告ベース ${percentageText(id)}(報告プレイヤー ${achieverCountText(id)})`; } function continuousPlayElapsedMs(now = Date.now()) { if (state.unlocked.continuous_play_24_hours) return CONTINUOUS_PLAY_24H_TARGET_MS; const timestamp = Number(now); - if (!continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0; - return Math.max(0, timestamp - continuousPlayStartedAt); + if (!Number.isFinite(timestamp)) return Math.max(0, continuousPlayAccumulatedMs); + const activeSegment = continuousPlayActive && !document.hidden && timestamp >= continuousPlayLastHeartbeatAt + ? timestamp - continuousPlayLastHeartbeatAt + : 0; + return Math.max(0, continuousPlayAccumulatedMs + activeSegment); } function formatElapsed(ms = 0) { @@ -397,29 +1042,18 @@ } function localProgressText(id) { - if (id === "great_mother_1000_births") { - const target = 3333; - const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.birthCount || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u4f53\uff09`; - } - if (id === "town_doctor_50") { - const target = 50; - const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.firstAidHeals || 0) || 0))); - return `\u73fe\u5728 ${count} / ${target}\u56de`; - } - if (id === "chaos_seeker_666_fights") { - const target = CHAOS_FIGHT_TARGET; - const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.fightMochiFightCount || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u56de\uff09`; - } - if (id === "fertility_seeker_721_love_births") { - const target = FERTILITY_BIRTH_TARGET; - const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.loveMochiBirthCount || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u56de\uff09`; - } - if (id === "daily_play_7_days") { - const count = state.unlocked[id] ? DAILY_PLAY_TARGET : Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress?.dailyPlayStreak || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / DAILY_PLAY_TARGET * 100).toFixed(1)}%\uff08${count} / ${DAILY_PLAY_TARGET}\u65e5\uff09`; + const definition = definitionById.get(String(id || "")); + const spec = definition?.progress || null; + if (spec?.display) { + const target = Math.max(1, Math.floor(Number(spec.target || 0) || 0)); + let count = 0; + if (spec.type === "counter") count = progressCounter(spec.key); + else if (spec.type === "value") count = Math.max(0, Math.floor(Number(ensureProgress()[spec.key] || 0) || 0)); + else if (spec.type === "unique") count = ensureProgress()[spec.key]?.length || 0; + count = state.unlocked[id] ? target : Math.min(target, count); + const unit = String(spec.unit || ""); + if (spec.display === "percent") return `現在の達成率 ${(count / target * 100).toFixed(1)}%(${count} / ${target}${unit})`; + return `現在 ${count} / ${target}${unit}`; } if (id === "elite_few") { const world = global.world || null; @@ -430,36 +1064,20 @@ ? Math.min(ELITE_FEW_TARGET_DAYS, (now - startedAt) / dayLength) : 0; const aliveCount = (world?.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0); - return `\u73fe\u5728 ${elapsedDays.toFixed(1)} / ${ELITE_FEW_TARGET_DAYS}\u65e5\uff08\u500b\u4f53\u6570 ${aliveCount} / ${ELITE_FEW_MAX_POPULATION}\u4ee5\u4e0b\uff09`; - } - if (id === "statistician") { - const count = state.unlocked[id] ? STATISTICIAN_TARGET : Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress?.statsButtonPressCount || 0) || 0))); - return `\u73fe\u5728 ${count} / ${STATISTICIAN_TARGET}\u56de`; - } - if (id === "lively_making") { - const count = state.unlocked[id] ? LIVELY_MAKING_TARGET : Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress?.manualTarinaiAddedCount || 0) || 0))); - return `\u73fe\u5728 ${count} / ${LIVELY_MAKING_TARGET}\u4f53`; - } - if (id === "memento_mori") { - const count = state.unlocked[id] ? MEMENTO_MORI_TARGET : Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress?.totalDeathCount || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / MEMENTO_MORI_TARGET * 100).toFixed(1)}%\uff08${count} / ${MEMENTO_MORI_TARGET}\u4f53\uff09`; - } - if (id === "mad_scientist") { - const count = state.unlocked[id] ? MAD_SCIENTIST_TARGET : Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0); - return `\u73fe\u5728 ${count} / ${MAD_SCIENTIST_TARGET}\u5339`; + return `現在 ${elapsedDays.toFixed(1)} / ${ELITE_FEW_TARGET_DAYS}日(個体数 ${aliveCount} / ${ELITE_FEW_MAX_POPULATION}以下)`; } if (id === "continuous_play_1_hour") { const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs()); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`; + return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 60:00)`; } if (id === "continuous_play_24_hours") { const elapsed = Math.min(CONTINUOUS_PLAY_24H_TARGET_MS, continuousPlayElapsedMs()); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_24H_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 1440:00\uff09`; + return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_24H_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 1440:00)`; } if (id === "enemy_enemy_friend") { const world = global.world || null; const count = state.unlocked[id] ? 10 : Math.min(10, Math.max(0, Math.floor(Number(world?.achievementEnemyAntKills || 0) || 0))); - return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count * 10).toFixed(1)}%\uff08${count} / 10\u4f53\uff09`; + return `現在の達成率 ${(count * 10).toFixed(1)}%(${count} / 10体)`; } return ""; } @@ -474,19 +1092,20 @@ ? DEFINITIONS.map(definition => `${percentageValue(definition.id).toFixed(1)}:${achieverCountValue(definition.id) ?? "-"}`).join(",") : shared.loading ? "loading" : "offline"; const local = [ - Math.min(3333, Math.max(0, Number(state.progress?.birthCount || 0) || 0)), + progressCounter("birthCount"), Math.floor(continuousPlayElapsedMs() / 1000), Math.max(0, Math.floor(Number(global.__tarinaiFps || 0) || 0)), Math.min(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)), - Math.min(50, Math.max(0, Number(state.progress?.firstAidHeals || 0) || 0)), - Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Number(state.progress?.fightMochiFightCount || 0) || 0)), - Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Number(state.progress?.loveMochiBirthCount || 0) || 0)), + progressCounter("firstAidHeals"), + progressCounter("fightMochiFightCount"), + progressCounter("loveMochiBirthCount"), Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)), Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0), - Math.min(STATISTICIAN_TARGET, Math.max(0, Number(state.progress?.statsButtonPressCount || 0) || 0)), - Math.min(MEMENTO_MORI_TARGET, Math.max(0, Number(state.progress?.totalDeathCount || 0) || 0)), - Math.min(LIVELY_MAKING_TARGET, Math.max(0, Number(state.progress?.manualTarinaiAddedCount || 0) || 0)), - Math.min(33, Math.max(0, Number(state.progress?.directFeedCount || 0) || 0)), + progressCounter("statsButtonPressCount"), + progressCounter("totalDeathCount"), + progressCounter("manualTarinaiAddedCount"), + progressCounter("directFeedCount"), + progressCounter("robotCleanCount"), Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0), Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10), ].join(":"); @@ -574,7 +1193,7 @@ dom.button?.classList.toggle("complete", count === DEFINITIONS.length); if (dom.sharedStatus) { if (shared.loading) dom.sharedStatus.textContent = "\u5171\u6709\u9054\u6210\u7387\u3092\u78ba\u8a8d\u4e2d\u2026"; - else if (shared.connected) dom.sharedStatus.textContent = `\u5171\u6709\u96c6\u8a08: ${shared.totalPlayers}\u4eba\u306e\u30d7\u30ec\u30a4\u30e4\u30fc`; + else if (shared.connected) dom.sharedStatus.textContent = `共有集計(端末報告値): ${shared.totalPlayers}人のプレイヤー`; else dom.sharedStatus.textContent = "\u5171\u6709\u9054\u6210\u7387: \u672a\u63a5\u7d9a\uff08\u30ed\u30fc\u30ab\u30eb\u5b9f\u7e3e\u306f\u5229\u7528\u53ef\u80fd\uff09"; } if (!dom.list || dom.dialog?.classList.contains("hidden")) return; @@ -693,15 +1312,22 @@ const options = payload ? { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify({ ...payload, playerSecret: playerSecret() }), signal: controller?.signal, cache: "no-store", } : { signal: controller?.signal, cache: "no-store" }; - const url = payload ? API_URL : `${API_URL}?action=summary&playerId=${encodeURIComponent(playerId())}`; + const url = payload ? API_URL : `${API_URL}?action=summary`; const response = await fetch(url, options); - if (!response.ok) throw new Error(`api ${response.status}`); - const data = await response.json(); - if (!data?.ok) throw new Error("api rejected"); + let data = null; + try { data = await response.json(); } catch (_) {} + if (!response.ok || !data?.ok) { + const error = new Error(`api ${response.status || 0}`); + error.status = Number(response.status || 0) || 0; + error.code = String(data?.error || "api_rejected"); + error.data = data; + throw error; + } + storeServerCredentials(data); return data; } finally { global.clearTimeout(timeout); @@ -714,6 +1340,7 @@ loading: false, totalPlayers: Math.max(0, Number(data?.totalPlayers || 0) || 0), achievements: data?.achievements && typeof data.achievements === "object" ? data.achievements : {}, + statisticsTrust: String(data?.statisticsTrust || "client-reported"), }; render(); } @@ -723,11 +1350,7 @@ shared.loading = true; render(); try { - const data = await request(options.session ? { - action: "session", - playerId: playerId(), - gameVersion: GAME_VERSION, - } : null); + const data = options.session ? await establishSession() : await request(null); if (serial === requestSerial) applyShared(data); return true; } catch (_) { @@ -748,35 +1371,209 @@ return unlocked; } + function localProgressSnapshot() { + const normalized = normalizeState(state); + return { + progress: JSON.parse(JSON.stringify(normalized.progress)), + ledger: JSON.parse(JSON.stringify(normalized._progressLedger)), + }; + } + + function completionistTimestamp() { + let latest = 0; + for (const definition of DEFINITIONS) { + if (definition.id === COMPLETIONIST_ID) continue; + const timestamp = Number(state.unlocked[definition.id] || 0) || 0; + if (!timestamp) return 0; + latest = Math.max(latest, timestamp); + } + return latest; + } + + function mergeServerUnlocks(data) { + const serverGenerationRaw = Number(data?.playerGeneration); + const hasServerGeneration = Number.isFinite(serverGenerationRaw) && serverGenerationRaw >= 0; + const serverGeneration = hasServerGeneration ? Math.floor(serverGenerationRaw) : persistenceGeneration; + if (serverGeneration < persistenceGeneration) return false; + + let changed = false; + if (serverGeneration > persistenceGeneration) { + // A reset committed by another tab/device wins over every older local tab. + const identity = currentServerIdentity(); + persistenceGeneration = serverGeneration; + persistenceRevision = 0; + state = blankState(); + if (identity) state.serverIdentity = { ...identity }; + progressMutationRevision = 0; + progressSyncedRevision = 0; + changed = true; + } + + const serverProgress = data?.playerProgress; + if (serverProgress && typeof serverProgress === "object" && !Array.isArray(serverProgress)) { + const beforeProgress = JSON.stringify({ progress: state.progress, ledger: state._progressLedger }); + const incoming = blankState(); + incoming.progress = serverProgress.progress && typeof serverProgress.progress === "object" ? serverProgress.progress : {}; + incoming._progressLedger = serverProgress.ledger && typeof serverProgress.ledger === "object" ? serverProgress.ledger : {}; + state = mergeStateData(state, incoming); + const afterProgress = JSON.stringify({ progress: state.progress, ledger: state._progressLedger }); + if (beforeProgress !== afterProgress) changed = true; + } + + const source = data?.playerUnlocked; + if (source && typeof source === "object" && !Array.isArray(source)) { + const serverIds = []; + for (const [id, serverTimestamp] of Object.entries(source)) { + if (!definitionById.has(id) || state.debugUnlocked?.includes?.(id)) continue; + const seconds = Number(serverTimestamp); + const serverTime = Number.isFinite(seconds) && seconds > 0 ? Math.max(1, Math.floor(seconds * 1000)) : Date.now(); + const localTime = Number(state.unlocked[id] || 0) || 0; + const sameSecond = localTime > 0 && Math.floor(localTime / 1000) === Math.floor(serverTime / 1000); + const mergedTime = localTime > 0 ? (sameSecond ? localTime : Math.min(localTime, serverTime)) : serverTime; + if (localTime !== mergedTime) { + state.unlocked[id] = mergedTime; + changed = true; + } + serverIds.push(id); + } + const confirmedSet = new Set(serverIds); + const beforePending = state.pending.length; + state.pending = state.pending.filter(id => !confirmedSet.has(id)); + if (state.pending.length !== beforePending) changed = true; + } + + const completionTime = completionistTimestamp(); + if (completionTime > 0) { + const current = Number(state.unlocked[COMPLETIONIST_ID] || 0) || 0; + const merged = current > 0 ? Math.max(current, completionTime) : completionTime; + if (current !== merged) { + state.unlocked[COMPLETIONIST_ID] = merged; + changed = true; + } + } + if (changed) { + saveState(); + render(); + } + return changed; + } + + async function establishSession(options = {}) { + const legacyId = legacyPlayerId(); + const payload = { + action: "session", + playerId: playerId(), + gameVersion: GAME_VERSION, + generation: persistenceGeneration, + }; + if (legacyId) payload.legacyPlayerId = legacyId; + const data = await request(payload); + if (options.merge !== false) mergeServerUnlocks(data); + return data; + } + async function performSyncPending(options = {}) { let latest = null; + const writeWithIdentityRecovery = async (payload, recoveryOptions = {}) => { + try { + const data = await request(payload); + mergeServerUnlocks(data); + return data; + } catch (error) { + const status = Number(error?.status || 0); + const code = String(error?.code || ""); + if (status === 409 && code === "stale_generation") { + // A reset from another device is authoritative. Merge it, but never + // replay this request's older-generation unlock/progress snapshot. + error.recoveredData = await establishSession({ merge: true }); + throw error; + } + if (status === 409 && code === "generation_ahead") { + // Compatibility recovery for servers that require an explicit full + // generation promotion after a backup restore or identity recreation. + const data = await request({ + action: "sync", + playerId: playerId(), + gameVersion: GAME_VERSION, + generation: persistenceGeneration, + generationRecovery: true, + syncProtocol: SYNC_PROTOCOL_VERSION, + mergeMode: "grow-only-union-v5", + unlocked: localUnlockSnapshot(), + progressState: localProgressSnapshot(), + }); + mergeServerUnlocks(data); + return data; + } + const recoverableIdentity = status === 403 && ["unknown_player", "invalid_owner"].includes(code); + if (!recoverableIdentity) throw error; + const sessionData = await establishSession({ merge: true }); + if (recoveryOptions.sessionOnly) return sessionData; + if (payload.action === "reset" && !state.resetPending) return sessionData; + const retryPayload = payload.action === "sync" ? { + action: "sync", + playerId: playerId(), + gameVersion: GAME_VERSION, + generation: persistenceGeneration, + generationRecovery: true, + syncProtocol: SYNC_PROTOCOL_VERSION, + mergeMode: "grow-only-union-v5", + unlocked: localUnlockSnapshot(), + progressState: localProgressSnapshot(), + } : { + ...payload, + playerId: playerId(), + generation: persistenceGeneration, + generationRecovery: true, + syncProtocol: SYNC_PROTOCOL_VERSION, + }; + const data = await request(retryPayload); + mergeServerUnlocks(data); + return data; + } + }; try { if (state.resetPending) { - latest = await request({ + latest = await writeWithIdentityRecovery({ action: "reset", playerId: playerId(), gameVersion: GAME_VERSION, - }); + confirmReset: "RESET_ACHIEVEMENTS", + catalogRevision: ACHIEVEMENT_CATALOG_REVISION, + generation: persistenceGeneration, + syncProtocol: SYNC_PROTOCOL_VERSION, + }, { mergeSession: false }); state.resetPending = false; saveState(); } + // Unlocks and progress are uploaded as one generation-scoped CRDT + // snapshot. The server unions actor shards, set-like progress and the + // earliest unlock timestamps, then returns the atomic merged state. const snapshot = localUnlockSnapshot(); + const progressSnapshot = localProgressSnapshot(); + const progressRevisionAtStart = progressMutationRevision; const pendingIds = [...new Set(state.pending.filter(id => definitionById.has(id) && snapshot[id]))]; const unlocked = options.full ? snapshot : Object.fromEntries(pendingIds.map(id => [id, snapshot[id]])); const syncedIds = Object.keys(unlocked); - if (syncedIds.length || options.full) { - latest = await request({ + const hasProgressChanges = progressRevisionAtStart > progressSyncedRevision; + if (options.full || syncedIds.length || hasProgressChanges) { + latest = await writeWithIdentityRecovery({ action: "sync", playerId: playerId(), gameVersion: GAME_VERSION, unlocked, - ...(options.full ? { completionistEligible: allOtherAchievementsUnlocked() } : {}), - }); - const syncedSet = new Set(syncedIds); - state.pending = state.pending.filter(id => !syncedSet.has(id)); + progressState: progressSnapshot, + mergeMode: "grow-only-union-v5", + syncProtocol: SYNC_PROTOCOL_VERSION, + generation: persistenceGeneration, + generationRecovery: true, + }, { mergeSession: false }); + const confirmedSet = new Set(syncedIds); + state.pending = state.pending.filter(id => !confirmedSet.has(id)); + progressSyncedRevision = Math.max(progressSyncedRevision, progressRevisionAtStart); saveState(); } else if (!latest) { return refreshShared({ session: true }); @@ -784,7 +1581,11 @@ if (latest) applyShared(latest); return true; - } catch (_) { + } catch (error) { + if (error?.recoveredData) { + applyShared(error.recoveredData); + return true; + } shared = { ...shared, connected: false, loading: false }; render(); return false; @@ -793,7 +1594,10 @@ function syncPending(options = {}) { const normalized = { full: Boolean(options.full) }; - const run = () => performSyncPending(normalized); + const run = async () => { + await persistenceReadyPromise; + return performSyncPending(normalized); + }; syncQueue = syncQueue.then(run, run); return syncQueue; } @@ -859,30 +1663,13 @@ return tracker; } - function generationRunLength(values = []) { - const sorted = [...new Set((values || []) - .map(value => Math.max(1, Math.floor(Number(value) || 0))) - .filter(Number.isFinite))].sort((a, b) => a - b); - let best = 0; - let run = 0; - let previous = null; - for (const generation of sorted) { - run = previous !== null && generation === previous + 1 ? run + 1 : 1; - best = Math.max(best, run); - previous = generation; - } - return best; - } - - function recordNaturalGeneration(world, tarinai, property, required) { + function recordNaturalGeneration(world, tarinai, depthKey, required) { if (!world || !tarinai) return 0; - const depthKey = property === "achievementNaturalSlaveGenerations" - ? "_achievementNaturalSlaveLineageDepth" - : "_achievementNaturalKingLineageDepth"; const parentIds = Array.isArray(tarinai.parents) ? tarinai.parents.filter(Boolean) : []; let parentDepth = 0; for (const parentId of parentIds) { - const parent = (world.tarinai || []).find(candidate => candidate && String(candidate.id || "") === String(parentId)); + const parent = (world.tarinai || []).find(candidate => candidate + && String(candidate.familyKey || candidate.id || "") === String(parentId)); parentDepth = Math.max(parentDepth, Math.max(0, Math.floor(Number(parent?.[depthKey] || 0) || 0))); } tarinai[depthKey] = Math.max(1, parentDepth + 1); @@ -903,32 +1690,57 @@ function ensureProgress() { if (!state.progress || typeof state.progress !== "object") state.progress = blankState().progress; if (!Array.isArray(state.progress.linkTypes)) state.progress.linkTypes = []; - state.progress.placementCount = Math.max(0, Math.floor(Number(state.progress.placementCount || 0) || 0)); + state.progress.linkTypes = [...new Set(state.progress.linkTypes.filter(type => LINK_PROGRESS_TYPES.includes(type)))]; state.progress.signalActivated = Boolean(state.progress.signalActivated); - state.progress.antKills = Math.max(0, Math.floor(Number(state.progress.antKills || 0) || 0)); - state.progress.birthCount = Math.max(0, Math.floor(Number(state.progress.birthCount || 0) || 0)); - state.progress.quickDeleteCount = Math.max(0, Math.floor(Number(state.progress.quickDeleteCount || 0) || 0)); - state.progress.undoCount = Math.max(0, Math.floor(Number(state.progress.undoCount || 0) || 0)); - state.progress.redoCount = Math.max(0, Math.floor(Number(state.progress.redoCount || 0) || 0)); - state.progress.directFeedCount = Math.min(33, Math.max(0, Math.floor(Number(state.progress.directFeedCount || 0) || 0))); if (!Array.isArray(state.progress.medicineTypes)) state.progress.medicineTypes = []; state.progress.medicineTypes = [...new Set(state.progress.medicineTypes.filter(type => MEDICINE_LEDGER_TYPES.includes(type)))]; - state.progress.firstAidHeals = Math.max(0, Math.floor(Number(state.progress.firstAidHeals || 0) || 0)); - state.progress.fightMochiFightCount = Math.max(0, Math.floor(Number(state.progress.fightMochiFightCount || 0) || 0)); - state.progress.shotCount = Math.max(0, Math.floor(Number(state.progress.shotCount || 0) || 0)); - state.progress.loveMochiBirthCount = Math.max(0, Math.floor(Number(state.progress.loveMochiBirthCount || 0) || 0)); state.progress.dailyPlayStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress.dailyPlayStreak || 0) || 0))); state.progress.dailyPlayLastDay = Math.max(0, Math.floor(Number(state.progress.dailyPlayLastDay || 0) || 0)); - state.progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress.statsButtonPressCount || 0) || 0))); - state.progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress.totalDeathCount || 0) || 0))); - state.progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress.manualTarinaiAddedCount || 0) || 0))); if (!Array.isArray(state.progress.mysteryDrugTarinaiIds)) state.progress.mysteryDrugTarinaiIds = []; state.progress.mysteryDrugTarinaiIds = [...new Set(state.progress.mysteryDrugTarinaiIds.map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET); return state.progress; } - function hexByte(value = 0) { - return Math.max(0, Math.min(255, Number(value) || 0)).toString(16).padStart(2, "0"); + function ensureProgressLedger() { + state._progressLedger = sanitizeProgressLedger(state, state.progress || {}); + return state._progressLedger; + } + + function progressCounter(key) { + return ledgerCounterValue(ensureProgressLedger(), key); + } + + function raiseProgressCounter(key, minimumValue = 0) { + if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; + const cap = ADDITIVE_PROGRESS_CAPS[key]; + const ledger = ensureProgressLedger(); + const current = ledgerCounterValue(ledger, key); + const target = Math.min(cap, Math.max(0, Math.floor(Number(minimumValue || 0) || 0))); + if (target <= current) return current; + ledger.base[key] = Math.min(cap, Math.max(0, Math.floor(Number(ledger.base[key] || 0) || 0)) + (target - current)); + if (target >= cap) { + ledger.base[key] = cap; + ledger.shards[key] = {}; + } + markProgressMutation(); + return target; + } + + function incrementProgressCounter(key, amount = 1) { + if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; + const ledger = ensureProgressLedger(); + const cap = ADDITIVE_PROGRESS_CAPS[key]; + const current = ledgerCounterValue(ledger, key); + const increment = Math.min(Math.max(0, Math.floor(Number(amount || 0) || 0)), Math.max(0, cap - current)); + if (increment <= 0) return current; + const shards = ledger.shards[key] || (ledger.shards[key] = {}); + shards[persistenceActorId] = Math.max(0, Math.floor(Number(shards[persistenceActorId] || 0) || 0)) + increment; + if (current + increment >= cap) { + ledger.base[key] = cap; + ledger.shards[key] = {}; + } + markProgressMutation(); + return current + increment; } function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0, extraWord = 0) { @@ -978,33 +1790,35 @@ baseTimestamp, deltas, [ - state.unlocked.placed_objects_100 ? 0 : Math.min(100, progress.placementCount), + state.unlocked.placed_objects_100 ? 0 : Math.min(100, progressCounter("placementCount")), state.unlocked.mechanized_industry ? 0 : linkFlags, - state.unlocked.ants_killed_100 ? 0 : Math.min(100, progress.antKills), - state.unlocked.great_mother_1000_births ? 0 : Math.min(3333, progress.birthCount), - state.unlocked.unplanned_city_30 ? 0 : Math.min(30, progress.quickDeleteCount), - state.unlocked.undo_20 ? 0 : Math.min(20, progress.undoCount), - state.unlocked.redo_20 ? 0 : Math.min(20, progress.redoCount), + state.unlocked.ants_killed_100 ? 0 : Math.min(100, progressCounter("antKills")), + state.unlocked.great_mother_1000_births ? 0 : Math.min(3333, progressCounter("birthCount")), + state.unlocked.unplanned_city_30 ? 0 : Math.min(30, progressCounter("quickDeleteCount")), + state.unlocked.undo_20 ? 0 : Math.min(20, progressCounter("undoCount")), + state.unlocked.redo_20 ? 0 : Math.min(20, progressCounter("redoCount")), state.unlocked.medicine_ledger_all ? 0 : medicineMask, - state.unlocked.town_doctor_50 ? 0 : Math.min(50, progress.firstAidHeals), - state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount), - state.unlocked.sniper_333_shots ? 0 : Math.min(SNIPER_SHOT_TARGET, progress.shotCount), - state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount), + state.unlocked.town_doctor_50 ? 0 : Math.min(50, progressCounter("firstAidHeals")), + state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progressCounter("fightMochiFightCount")), + state.unlocked.sniper_333_shots ? 0 : Math.min(SNIPER_SHOT_TARGET, progressCounter("shotCount")), + state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progressCounter("loveMochiBirthCount")), state.unlocked.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak), state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay), - state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount), - state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount), - state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount), + state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progressCounter("statsButtonPressCount")), + state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progressCounter("totalDeathCount")), + state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progressCounter("manualTarinaiAddedCount")), state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length), - state.unlocked.direct_feed_33 ? 0 : Math.min(33, progress.directFeedCount), + state.unlocked.direct_feed_33 ? 0 : Math.min(33, progressCounter("directFeedCount")), + state.unlocked.robot_cleaner_100 ? 0 : Math.min(ROBOT_CLEAN_TARGET, progressCounter("robotCleanCount")), ], + state.unlocked.mad_scientist ? [] : progress.mysteryDrugTarinaiIds.slice(0, MAD_SCIENTIST_TARGET), ]; } function parseSpellState(payload = null) { if (!Array.isArray(payload)) return null; const version = Number(payload[0]); - if (version !== SPELL_STATE_VERSION) return null; + if (![9, 10, SPELL_STATE_VERSION].includes(version)) return null; const lowWord = Number(payload[1]); const highWord = Number(payload[2]); const extraWord = Number(payload[3]); @@ -1019,6 +1833,10 @@ baseTimestamp: Math.max(0, Math.floor(Number(payload[4] || 0) || 0)), deltas: Array.isArray(payload[5]) ? payload[5] : [], packed: Array.isArray(payload[6]) ? payload[6] : [], + mysteryDrugIds: version >= 10 && Array.isArray(payload[7]) + ? [...new Set(payload[7].map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET) + : [], + version, timestampUnitMs: 60000, }; } @@ -1026,7 +1844,7 @@ function importSpellState(payload = null) { const parsed = parseSpellState(payload); if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false }; - const { unlockedIndexes, baseTimestamp, deltas, packed, timestampUnitMs } = parsed; + const { unlockedIndexes, baseTimestamp, deltas, packed, mysteryDrugIds, version, timestampUnitMs } = parsed; if (deltas.length !== unlockedIndexes.length) throw new Error("invalid achievement spell timestamps"); const importedUnlocks = []; let importedCompletionistAt = 0; @@ -1058,8 +1876,8 @@ } const progress = ensureProgress(); - const previousProgress = JSON.stringify(progress); - progress.placementCount = Math.max(progress.placementCount, Math.min(100, Math.max(0, Math.floor(Number(packed[0] || 0) || 0)))); + const previousProgress = JSON.stringify({ progress, ledger: ensureProgressLedger() }); + raiseProgressCounter("placementCount", packed[0]); const linkFlags = Math.max(0, Math.floor(Number(packed[1] || 0) || 0)); const importedLinks = []; if (linkFlags & 1) importedLinks.push("rope"); @@ -1067,18 +1885,18 @@ if (linkFlags & 4) importedLinks.push("spring"); progress.linkTypes = [...new Set([...(progress.linkTypes || []), ...importedLinks])]; progress.signalActivated = Boolean(progress.signalActivated || (linkFlags & 8)); - progress.antKills = Math.max(progress.antKills, Math.min(100, Math.max(0, Math.floor(Number(packed[2] || 0) || 0)))); - progress.birthCount = Math.max(progress.birthCount, Math.min(3333, Math.max(0, Math.floor(Number(packed[3] || 0) || 0)))); - progress.quickDeleteCount = Math.max(progress.quickDeleteCount, Math.min(30, Math.max(0, Math.floor(Number(packed[4] || 0) || 0)))); - progress.undoCount = Math.max(progress.undoCount, Math.min(20, Math.max(0, Math.floor(Number(packed[5] || 0) || 0)))); - progress.redoCount = Math.max(progress.redoCount, Math.min(20, Math.max(0, Math.floor(Number(packed[6] || 0) || 0)))); + raiseProgressCounter("antKills", packed[2]); + raiseProgressCounter("birthCount", packed[3]); + raiseProgressCounter("quickDeleteCount", packed[4]); + raiseProgressCounter("undoCount", packed[5]); + raiseProgressCounter("redoCount", packed[6]); const medicineMask = Math.max(0, Math.floor(Number(packed[7] || 0) || 0)); const importedMedicines = MEDICINE_LEDGER_TYPES.filter((_type, index) => medicineMask & (1 << index)); progress.medicineTypes = [...new Set([...(progress.medicineTypes || []), ...importedMedicines])]; - progress.firstAidHeals = Math.max(progress.firstAidHeals, Math.min(50, Math.max(0, Math.floor(Number(packed[8] || 0) || 0)))); - progress.fightMochiFightCount = Math.max(progress.fightMochiFightCount, Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Math.floor(Number(packed[9] || 0) || 0)))); - progress.shotCount = Math.max(progress.shotCount, Math.min(SNIPER_SHOT_TARGET, Math.max(0, Math.floor(Number(packed[10] || 0) || 0)))); - progress.loveMochiBirthCount = Math.max(progress.loveMochiBirthCount, Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Math.floor(Number(packed[11] || 0) || 0)))); + raiseProgressCounter("firstAidHeals", packed[8]); + raiseProgressCounter("fightMochiFightCount", packed[9]); + raiseProgressCounter("shotCount", packed[10]); + raiseProgressCounter("loveMochiBirthCount", packed[11]); const importedDailyStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(packed[12] || 0) || 0))); const importedDailyLastDay = Math.min(131071, Math.max(0, Math.floor(Number(packed[13] || 0) || 0))); if (importedDailyLastDay > progress.dailyPlayLastDay) { @@ -1087,15 +1905,16 @@ } else if (importedDailyLastDay === progress.dailyPlayLastDay) { progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak); } - progress.statsButtonPressCount = Math.max(progress.statsButtonPressCount, Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(packed[14] || 0) || 0)))); - progress.totalDeathCount = Math.max(progress.totalDeathCount, Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(packed[15] || 0) || 0)))); - progress.manualTarinaiAddedCount = Math.max(progress.manualTarinaiAddedCount, Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(packed[16] || 0) || 0)))); - const importedMysteryDrugCount = Math.min(MAD_SCIENTIST_TARGET, Math.max(0, Math.floor(Number(packed[17] || 0) || 0))); - while (progress.mysteryDrugTarinaiIds.length < importedMysteryDrugCount) { - progress.mysteryDrugTarinaiIds.push(`imported:${progress.mysteryDrugTarinaiIds.length + 1}`); + raiseProgressCounter("statsButtonPressCount", packed[14]); + raiseProgressCounter("totalDeathCount", packed[15]); + raiseProgressCounter("manualTarinaiAddedCount", packed[16]); + if (version >= 10 && mysteryDrugIds.length) { + progress.mysteryDrugTarinaiIds = [...new Set([...(progress.mysteryDrugTarinaiIds || []), ...mysteryDrugIds])].slice(0, MAD_SCIENTIST_TARGET); } - progress.directFeedCount = Math.max(progress.directFeedCount, Math.min(33, Math.max(0, Math.floor(Number(packed[18] || 0) || 0)))); - const progressChanged = previousProgress !== JSON.stringify(progress); + raiseProgressCounter("directFeedCount", packed[18]); + raiseProgressCounter("robotCleanCount", packed[19]); + const progressChanged = previousProgress !== JSON.stringify({ progress, ledger: ensureProgressLedger() }); + if (progressChanged) markProgressMutation(); changed = changed || progressChanged; if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) { @@ -1131,11 +1950,10 @@ changed = evaluateMegalopolis(world, detail) || changed; changed = evaluateSandbox(world, detail) || changed; if (!state.unlocked.placed_objects_100) { - const progress = ensureProgress(); - progress.placementCount = Math.min(100, progress.placementCount + 1); + const count = incrementProgressCounter("placementCount"); saveState(); renderIfDialogOpen(); - if (progress.placementCount >= 100) changed = unlock("placed_objects_100", { ...detail, world, count: progress.placementCount }) || changed; + if (count >= ADDITIVE_PROGRESS_CAPS.placementCount) changed = unlock("placed_objects_100", { ...detail, world, count }) || changed; } return changed; } @@ -1151,15 +1969,14 @@ const placedAtRaw = item._achievementPlacedAt; const placedAt = placedAtRaw == null ? NaN : Number(placedAtRaw); if (!Number.isFinite(placedAt) || now < placedAt || now - placedAt > 30.0001) return changed; - const progress = ensureProgress(); if (!state.unlocked.unplanned_city_30) { const operationKey = detail.operationId == null ? "" : String(detail.operationId); if (operationKey && world._achievementLastQuickDeleteOperation === operationKey) return changed; if (operationKey) world._achievementLastQuickDeleteOperation = operationKey; - progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1); + const count = incrementProgressCounter("quickDeleteCount"); saveState(); renderIfDialogOpen(); - if (progress.quickDeleteCount >= 30) changed = unlock("unplanned_city_30", { ...detail, world, count: progress.quickDeleteCount }) || changed; + if (count >= ADDITIVE_PROGRESS_CAPS.quickDeleteCount) changed = unlock("unplanned_city_30", { ...detail, world, count }) || changed; } return changed; } @@ -1169,24 +1986,39 @@ if (action !== "undo" && action !== "redo") return false; const world = detailWorld(detail); if (world) recordIntervention({ ...detail, world, tool: action }); - const progress = ensureProgress(); const key = action === "undo" ? "undoCount" : "redoCount"; const achievementId = action === "undo" ? "undo_20" : "redo_20"; - progress[key] = Math.min(20, progress[key] + 1); + const count = incrementProgressCounter(key); saveState(); renderIfDialogOpen(); let changed = false; if (action === "undo" && Math.max(0, Number(detail.deadRestored || 0) || 0) >= 10) { changed = unlock("undo_mass_revival", { ...detail, count: Math.max(0, Number(detail.deadRestored || 0) || 0) }) || changed; } - if (progress[key] >= 20) changed = unlock(achievementId, { ...detail, count: progress[key] }) || changed; + if (count >= ADDITIVE_PROGRESS_CAPS[key]) changed = unlock(achievementId, { ...detail, count }) || changed; return changed; } + function mechanizedIndustryHasAllLinks(world = global.world) { + if (!world) return false; + return ["rope", "rod", "spring"].every(required => { + const bucket = world.itemsOfType?.(required); + if (Array.isArray(bucket)) return bucket.some(item => item && !item.dead); + return (world.items || []).some(item => item && !item.dead && item.type === required); + }); + } + function evaluateMechanizedIndustry(detail = {}) { - const progress = ensureProgress(); - const hasAllLinks = ["rope", "rod", "spring"].every(required => progress.linkTypes.includes(required)); - if (progress.signalActivated && hasAllLinks) return unlock("mechanized_industry", detail); + if (state.unlocked.mechanized_industry) return false; + const world = detailWorld(detail); + const source = detail.source || null; + const detectorDriven = Boolean(detail.detectorDriven) + || source?.type === "pressure_switch" + || (Array.isArray(detail.detectorSources) && detail.detectorSources.some(candidate => candidate?.type === "pressure_switch")); + const connectedTarget = Boolean(detail.connected) && detail.target && !detail.target.dead; + if (world && detectorDriven && connectedTarget && mechanizedIndustryHasAllLinks(world)) { + return unlock("mechanized_industry", { ...detail, world }); + } return false; } @@ -1194,23 +2026,10 @@ const linkType = String(type || detail.type || detail.item?.type || ""); const placementLinkTypes = ["rope", "rod", "spring", "wire", "insulated_wire"]; if (!placementLinkTypes.includes(linkType)) return false; - recordPlayerPlacement(detail); - if (state.unlocked.mechanized_industry || !["rope", "rod", "spring"].includes(linkType)) return false; - const progress = ensureProgress(); - progress.linkTypes = [...new Set([...(progress.linkTypes || []), linkType])]; - saveState(); - renderIfDialogOpen(); - return evaluateMechanizedIndustry({ ...detail, type: linkType }); + return recordPlayerPlacement(detail); } function recordSignalActivation(detail = {}) { - if (state.unlocked.mechanized_industry) return false; - const progress = ensureProgress(); - if (!progress.signalActivated) { - progress.signalActivated = true; - saveState(); - renderIfDialogOpen(); - } return evaluateMechanizedIndustry(detail); } @@ -1267,13 +2086,12 @@ function recordAntKilled(detail = {}) { let changed = false; - const progress = ensureProgress(); const playerCaused = Boolean(detail.playerCaused || detail.ant?._achievementLastDamageSource?.danger); if (!state.unlocked.ants_killed_100 && playerCaused) { - progress.antKills = Math.min(100, progress.antKills + 1); + const count = incrementProgressCounter("antKills"); saveState(); renderIfDialogOpen(); - if (progress.antKills >= 100) changed = unlock("ants_killed_100", detail) || changed; + if (count >= ADDITIVE_PROGRESS_CAPS.antKills) changed = unlock("ants_killed_100", { ...detail, count }) || changed; } const world = detailWorld(detail); const ant = detail.ant || null; @@ -1305,11 +2123,12 @@ const world = detail.world || tarinai.world || global.world || null; const now = Math.max(0, Number(world?.time || 0) || 0); const dangerSource = dangerItemSource(source); - // This achievement tracks collateral damage caused by danger-category tools, - // not ordinary combat, hunger, disease, or ant attacks. - if (world && dangerSource) { - world.achievementLastTarinaiDamageAt = now; - world.achievementEnemyAntKills = 0; + // "Unharmed" means no actual damage from any cause while the streak is active. + if (world) { + if (Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) > 0) { + world.achievementEnemyAntKills = 0; + renderIfDialogOpen(); + } } tarinai._achievementLastDamageSource = dangerSource ? { danger: true, @@ -1352,32 +2171,38 @@ const now = Math.max(0, Number(world.time || 0) || 0); const source = tarinai._achievementLastDamageSource || null; const lethalDanger = Boolean(detail.fromDamage || detail.achievementDangerKill); + const session = tarinai._achievementFightSession || null; if (!lethalDanger || !source?.danger || Math.abs(now - Number(source.at || 0)) > 0.05) return false; + if (!session?.id || !session.opponentId || Number(tarinai.fightTimer || 0) <= 0.04) return false; const tracker = fightPairTracker(world); if (!tracker) return false; + const maxGap = 6; for (const [key, pending] of [...tracker.entries()]) { - if (!pending) { + if (!pending || now - Number(pending.at || 0) > maxGap) { tracker.delete(key); continue; } - if (pending.firstId !== tarinai.id && pending.otherId === tarinai.id) { + if (pending.sessionId === session.id + && pending.firstId !== tarinai.id + && pending.otherId === tarinai.id + && session.opponentId === pending.firstId) { tracker.delete(key); - return unlock("fight_pair_danger_kill", { ...detail, pairKey: key, first: pending, secondSource: source }); + return unlock("fight_pair_danger_kill", { ...detail, pairKey: key, fightSessionId: session.id, first: pending, secondSource: source }); } } - const targets = [...new Set([...(Array.isArray(tarinai.fightTargetIds) ? tarinai.fightTargetIds : []), tarinai.fightTargetId].filter(Boolean))]; - for (const targetId of targets) { - const opponent = (world.tarinai || []).find(candidate => candidate && candidate.id === targetId) || null; - if (!opponent || opponent === tarinai) continue; - const activePair = Boolean(world.isAlreadyFightingPair?.(tarinai, opponent)) - || Boolean((tarinai.fightTimer || 0) > 0.04 && (opponent.fightTimer || 0) > 0.04); - if (!activePair) continue; - const key = world.fightPairKey?.(tarinai, opponent) - || [String(tarinai.id || ""), String(opponent.id || "")].sort().join(":"); - if (!key) continue; - tracker.set(key, { firstId: tarinai.id, otherId: opponent.id, at: now, source }); - return false; - } + const opponent = (world.tarinai || []).find(candidate => candidate && candidate.id === session.opponentId) || null; + if (!opponent || opponent === tarinai || opponent.dead) return false; + const opponentSession = opponent._achievementFightSession || null; + if (opponentSession?.id !== session.id || opponentSession.opponentId !== tarinai.id) return false; + const key = String(session.pairKey || world.fightPairKey?.(tarinai, opponent) || ""); + if (!key) return false; + tracker.set(key, { + firstId: tarinai.id, + otherId: opponent.id, + at: now, + source, + sessionId: session.id, + }); return false; } @@ -1465,6 +2290,7 @@ ? Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak + 1) : 1; progress.dailyPlayLastDay = today; + markProgressMutation(); saveState(); renderIfDialogOpen(); if (progress.dailyPlayStreak >= DAILY_PLAY_TARGET) { @@ -1481,9 +2307,7 @@ elapsedMs = Math.max(0, explicitElapsed); } else { const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now(); - if (!continuousPlayStartedAt || timestamp < continuousPlayStartedAt) continuousPlayStartedAt = timestamp; - continuousPlayLastHeartbeatAt = timestamp; - elapsedMs = Math.max(0, timestamp - continuousPlayStartedAt); + elapsedMs = continuousPlayElapsedMs(timestamp); } let changed = false; if (!state.unlocked.continuous_play_1_hour && elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) { @@ -1504,35 +2328,36 @@ if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) return false; return unlock("below_absolute_zero_item", { ...detail, world: worldRef, item, temperature }); }; - const ambient = Number(worldRef.currentTemperature); - if (Number.isFinite(ambient) && ambient < ABSOLUTE_ZERO_C) { - for (const bucket of worldRef.itemTypeBuckets?.values?.() || []) { - for (const item of bucket || []) if (testItem(item)) return true; - } - return false; + // Evaluate the actual temperature field for every live item. This avoids + // coupling the achievement to a hard-coded list of present-day heat sources. + for (const item of worldRef.items || []) { + if (testItem(item)) return true; } - const seen = new Set(); - for (const sourceType of ["dry_ice", "fan"]) { - for (const source of worldRef.itemsOfType?.(sourceType) || []) { - if (!source || source.dead) continue; - for (const item of worldRef.nearbyNonGrassItems?.(source.x, source.y, 360) || []) { - if (!item || item.dead || seen.has(item)) continue; - seen.add(item); - if (testItem(item)) return true; - } - } + for (const residue of worldRef.residues || []) { + if (testItem(residue)) return true; } return false; } + function importWorldAchievementProgress(detail = {}) { + const imported = Math.min(ROBOT_CLEAN_TARGET, Math.max(0, Math.floor(Number(detail.robotCleanCount || 0) || 0))); + const current = progressCounter("robotCleanCount"); + if (imported <= current) return false; + raiseProgressCounter("robotCleanCount", imported); + saveState(); + renderIfDialogOpen(); + return true; + } + function recordRobotClean(detail = {}) { if (state.unlocked.robot_cleaner_100) return false; const world = detailWorld(detail); const type = String(detail.type || detail.target?.type || ""); if (!world || !["zunchi", "ant_corpse", "trace", "splat"].includes(type)) return false; - world.achievementRobotCleanCount = Math.min(ROBOT_CLEAN_TARGET, Math.max(0, Math.floor(Number(world.achievementRobotCleanCount || 0) || 0)) + 1); + const count = incrementProgressCounter("robotCleanCount"); + if (count >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count }); + saveState(); renderIfDialogOpen(); - if (world.achievementRobotCleanCount >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count: world.achievementRobotCleanCount }); return false; } @@ -1549,6 +2374,7 @@ const liveItems = world.itemsOfType?.("robot_cleaner") || []; if ((world.tarinai || []).some(tarinai => tarinai && !tarinai.dead)) return false; if ((world.ants || []).some(ant => ant && !ant.dead)) return false; + if ((world.residues || []).some(residue => residue && !residue.dead && Math.max(0, Number(residue.amount ?? 1) || 0) > 0)) return false; const qualifies = liveItems.some(robot => { const mask = Number(global.TarinaiItemDynamicToolSystem?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? 0) | 0; return (mask & ROBOT_ALL_TARGET_MASK) === ROBOT_ALL_TARGET_MASK @@ -1581,11 +2407,10 @@ if (state.unlocked.town_doctor_50) return false; const recovered = Number(detail.recovered ?? detail.amount ?? 0); if (!Number.isFinite(recovered) || recovered <= 0.0001) return false; - const progress = ensureProgress(); - progress.firstAidHeals = Math.min(50, progress.firstAidHeals + 1); + const count = incrementProgressCounter("firstAidHeals"); saveState(); renderIfDialogOpen(); - if (progress.firstAidHeals >= 50) return unlock("town_doctor_50", { ...detail, count: progress.firstAidHeals, recovered }); + if (count >= ADDITIVE_PROGRESS_CAPS.firstAidHeals) return unlock("town_doctor_50", { ...detail, count, recovered }); return false; } @@ -1599,10 +2424,7 @@ const world = detailWorld(detail); let changed = false; if (type === "fight_mochi" && tarinai && world) { - const serial = Math.max(1, Math.floor(Number(world.achievementFightMochiSerial || 0) || 0) + 1); - world.achievementFightMochiSerial = serial; tarinai._achievementFightMochiAt = Math.max(0, Number(world.time || 0) || 0); - tarinai._achievementFightMochiSerial = serial; tarinai._achievementFightMochiWorld = world; } if (type === "mystery_drug" && tarinai && !state.unlocked.mad_scientist) { @@ -1610,6 +2432,7 @@ const identity = String(tarinai.familyKey || tarinai.id || ""); if (identity && !progress.mysteryDrugTarinaiIds.includes(identity)) { progress.mysteryDrugTarinaiIds = [...progress.mysteryDrugTarinaiIds, identity].slice(0, MAD_SCIENTIST_TARGET); + markProgressMutation(); if (progress.mysteryDrugTarinaiIds.length >= MAD_SCIENTIST_TARGET) { changed = unlock("mad_scientist", { ...detail, world, tarinai, count: progress.mysteryDrugTarinaiIds.length }) || changed; } else { @@ -1622,6 +2445,7 @@ const progress = ensureProgress(); if (!progress.medicineTypes.includes(type)) { progress.medicineTypes = [...progress.medicineTypes, type]; + markProgressMutation(); saveState(); renderIfDialogOpen(); } @@ -1644,7 +2468,6 @@ if (foodGift) { tarinai._achievementDirectFeedCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectFeedCount || 0) || 0)) + 1; world.achievementSelfSufficientStartAt = -1; - world.achievementLastDirectFeedAt = Math.max(0, Number(world.time || 0) || 0); } if (treatment) tarinai._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectTreatmentCount || 0) || 0)) + 1; recordConsumableUse({ ...detail, world, tarinai, type }); @@ -1662,24 +2485,32 @@ (Number(tarinai.fightMochiTimer || 0) > 0.04) || Boolean(tarinai.hasFightMochiEffect?.()) )); if (!state.unlocked.chaos_seeker_666_fights && fightMochiInfluenced) { - const progress = ensureProgress(); - progress.fightMochiFightCount = Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount + 1); - if (progress.fightMochiFightCount >= CHAOS_FIGHT_TARGET) { - changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightMochiFightCount, fightMochiInfluenced: true }) || changed; + const count = incrementProgressCounter("fightMochiFightCount"); + if (count >= CHAOS_FIGHT_TARGET) { + changed = unlock("chaos_seeker_666_fights", { ...detail, world, count, fightMochiInfluenced: true }) || changed; } else { saveState(); renderIfDialogOpen(); } } const now = Math.max(0, Number(world.time || 0) || 0); + const pairA = detail.a || null; + const pairB = detail.b || null; + if (pairA && pairB && pairA !== pairB) { + world.achievementFightSessionSerial = Math.max(0, Math.floor(Number(world.achievementFightSessionSerial || 0) || 0)) + 1; + const pairKey = world.fightPairKey?.(pairA, pairB) + || [String(pairA.id || ""), String(pairB.id || "")].sort().join(":"); + const sessionId = `${pairKey}@${world.achievementFightSessionSerial}`; + pairA._achievementFightSession = { id: sessionId, pairKey, opponentId: pairB.id, startedAt: now }; + pairB._achievementFightSession = { id: sessionId, pairKey, opponentId: pairA.id, startedAt: now }; + } const candidates = [detail.a, detail.b].filter(Boolean); for (const tarinai of candidates) { const givenAtRaw = tarinai._achievementFightMochiAt; const givenAt = givenAtRaw == null ? NaN : Number(givenAtRaw); - const serial = Math.floor(Number(tarinai._achievementFightMochiSerial || 0) || 0); - const validCurrentSessionMochi = serial > 0 && tarinai._achievementFightMochiWorld === world; + const validCurrentSessionMochi = tarinai._achievementFightMochiWorld === world; if (validCurrentSessionMochi && Number.isFinite(givenAt) && now >= givenAt && now - givenAt <= 30.0001) { - changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt, serial }) || changed; + changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt }) || changed; break; } } @@ -1688,9 +2519,8 @@ function recordShotFired(detail = {}) { if (state.unlocked.sniper_333_shots) return false; - const progress = ensureProgress(); - progress.shotCount = Math.min(SNIPER_SHOT_TARGET, progress.shotCount + 1); - if (progress.shotCount >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count: progress.shotCount }); + const count = incrementProgressCounter("shotCount"); + if (count >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count }); saveState(); renderIfDialogOpen(); return false; @@ -1698,10 +2528,9 @@ function recordLoveMochiBirth(detail = {}) { if (state.unlocked.fertility_seeker_721_love_births || !detail.loveMochiInfluenced) return false; - const progress = ensureProgress(); - progress.loveMochiBirthCount = Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount + 1); - if (progress.loveMochiBirthCount >= FERTILITY_BIRTH_TARGET) { - return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount }); + const count = incrementProgressCounter("loveMochiBirthCount"); + if (count >= FERTILITY_BIRTH_TARGET) { + return unlock("fertility_seeker_721_love_births", { ...detail, count }); } saveState(); renderIfDialogOpen(); @@ -1724,41 +2553,28 @@ if (!world) return false; runtimeByWorld.delete(world); dangerFightDeathsByWorld.delete(world); - world.achievementRobotCleanCount = 0; world.achievementEnemyAntKills = 0; - world.achievementLastTarinaiDamageAt = -Infinity; world.achievementSelfSufficientStartAt = -1; - world.achievementLastDirectFeedAt = -Infinity; world.achievementNoDeathStartAt = -1; world.achievementHappyStartAt = -1; - world.achievementFightMochiSerial = 0; world.achievementEliteFewStartAt = -1; for (const tarinai of world.tarinai || []) { if (!tarinai) continue; tarinai._achievementFightMochiAt = null; - tarinai._achievementFightMochiSerial = 0; tarinai._achievementFightMochiWorld = null; tarinai._achievementDirectFeedCount = 0; tarinai._achievementDirectTreatmentCount = 0; tarinai._achievementSaunaHotAt = null; tarinai._achievementSaunaWasHot = false; tarinai._achievementHeldStartedAt = null; + tarinai._achievementFightSession = null; } for (const item of world.items || []) { if (!item) continue; if (item.type === "sticky_bomb") item.stickyBombPassCount = 0; item._achievementPlacedAt = null; } - world.achievementNaturalSlaveGenerations = []; - world.achievementNaturalKingGenerations = []; - if (!state.unlocked.mad_scientist) { - const progress = ensureProgress(); - if (progress.mysteryDrugTarinaiIds.length) { - progress.mysteryDrugTarinaiIds = []; - saveState(); - renderIfDialogOpen(); - } - } + // Mystery-drug targets are a lifetime achievement and intentionally survive world resets. return true; } @@ -1795,12 +2611,11 @@ if (child?.isZunchiSlave) recordNaturalStatus("slave", { ...detail, world, tarinai: child, bornWithStatus: true }); if (child?.isTarinaiChampion) recordNaturalStatus("king", { ...detail, world, tarinai: child, bornWithStatus: true }); evaluateMegalopolis(world, detail); - const progress = ensureProgress(); if (!state.unlocked.great_mother_1000_births) { - progress.birthCount = Math.min(3333, progress.birthCount + 1); + const count = incrementProgressCounter("birthCount"); saveState(); renderIfDialogOpen(); - if (progress.birthCount >= 3333) unlock("great_mother_1000_births", { ...detail, count: progress.birthCount }); + if (count >= ADDITIVE_PROGRESS_CAPS.birthCount) unlock("great_mother_1000_births", { ...detail, count }); } if (state.unlocked.birth_50_in_60_seconds) return; const now = Number(world?.time || 0) || 0; @@ -1814,9 +2629,8 @@ function recordDeath(detail = {}) { recordFightPairDangerDeath(detail); if (!state.unlocked.memento_mori) { - const progress = ensureProgress(); - progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount + 1); - if (progress.totalDeathCount >= MEMENTO_MORI_TARGET) unlock("memento_mori", { ...detail, count: progress.totalDeathCount }); + const count = incrementProgressCounter("totalDeathCount"); + if (count >= MEMENTO_MORI_TARGET) unlock("memento_mori", { ...detail, count }); else { scheduleProgressSave(); renderIfDialogOpen(); } } const reason = String(detail.reason || detail.tarinai?.deathReason || ""); @@ -1857,7 +2671,7 @@ const world = detail.world || tarinai?.world || null; if (kind === "slave") { unlock("natural_zunchi_slave", detail); - if (recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)) { + if (recordNaturalGeneration(world, tarinai, "_achievementNaturalSlaveLineageDepth", 5)) { unlock("natural_zunchi_slave_5_generations", detail); } evaluateStatusEffectAchievements(tarinai, detail); @@ -1865,7 +2679,7 @@ } if (kind === "king") { unlock("natural_tarinai_king", detail); - if (recordNaturalGeneration(world, tarinai, "achievementNaturalKingGenerations", 3)) { + if (recordNaturalGeneration(world, tarinai, "_achievementNaturalKingLineageDepth", 3)) { unlock("natural_tarinai_king_3_generations", detail); } evaluateStatusEffectAchievements(tarinai, detail); @@ -1877,11 +2691,10 @@ if (!world) return false; recordDirectCare({ ...detail, world, foodGift: true }); if (state.unlocked.direct_feed_33) return false; - const progress = ensureProgress(); - progress.directFeedCount = Math.min(33, progress.directFeedCount + 1); + const count = incrementProgressCounter("directFeedCount"); saveState(); renderIfDialogOpen(); - if (progress.directFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: progress.directFeedCount }); + if (count >= ADDITIVE_PROGRESS_CAPS.directFeedCount) return unlock("direct_feed_33", { ...detail, world, count }); return false; } @@ -1898,10 +2711,9 @@ function recordStatsButtonPress(detail = {}) { if (state.unlocked.statistician) return false; - const progress = ensureProgress(); - progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount + 1); - if (progress.statsButtonPressCount >= STATISTICIAN_TARGET) { - return unlock("statistician", { ...detail, count: progress.statsButtonPressCount }); + const count = incrementProgressCounter("statsButtonPressCount"); + if (count >= STATISTICIAN_TARGET) { + return unlock("statistician", { ...detail, count }); } saveState(); renderIfDialogOpen(); @@ -1918,10 +2730,9 @@ // the no-operation window used by the surveillance-camera achievement. recordIntervention({ ...detail, tool: detail.tool || "new-tarinai" }); if (state.unlocked.lively_making) return false; - const progress = ensureProgress(); - progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount + 1); - if (progress.manualTarinaiAddedCount >= LIVELY_MAKING_TARGET) { - return unlock("lively_making", { ...detail, count: progress.manualTarinaiAddedCount }); + const count = incrementProgressCounter("manualTarinaiAddedCount"); + if (count >= LIVELY_MAKING_TARGET) { + return unlock("lively_making", { ...detail, count }); } saveState(); renderIfDialogOpen(); @@ -2036,7 +2847,7 @@ const moodId = String(mood?.id || worldRef.colonyMood?.id || ""); const day = Math.max(1, Math.floor(Number(worldRef.day || 1) || 1)); if (alive.length > 0 && moodId === "happy" && day >= 6 && !state.unlocked.minimalist_happy) { - const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount ?? worldRef.achievementPlayerPlacementCount ?? 0) || 0); + const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount || 0) || 0); if (placedAlive <= 10) unlock("minimalist_happy", { world: worldRef, mood: mood || worldRef.colonyMood, placedAlive }); } if (!state.unlocked.colony_happy) { @@ -2134,6 +2945,7 @@ } async function resetAchievements() { + await persistenceReadyPromise; const confirmed = await global.TarinaiGameDialogs?.confirm?.({ title: "\u5b9f\u7e3e\u306e\u30ea\u30bb\u30c3\u30c8", message: "\u3059\u3079\u3066\u306e\u5b9f\u7e3e\u89e3\u9664\u72b6\u614b\u3068\u3001\u5171\u6709\u96c6\u8a08\u4e0a\u306e\u81ea\u5206\u306e\u89e3\u9664\u8a18\u9332\u3092\u524a\u9664\u3057\u307e\u3059\u3002", @@ -2142,8 +2954,14 @@ danger: true, }); if (!confirmed) return false; + const identity = currentServerIdentity(); + persistenceGeneration += 1; + persistenceRevision = 0; state = blankState(); + if (identity) state.serverIdentity = { ...identity }; state.resetPending = true; + progressMutationRevision = 0; + progressSyncedRevision = 0; runtimeByWorld = new WeakMap(); dangerFightDeathsByWorld = new WeakMap(); wireShockTrackers = new WeakMap(); @@ -2151,8 +2969,9 @@ toastActive = false; emptyClickTimes = []; groundChangeTimes = []; - continuousPlayStartedAt = Date.now(); - continuousPlayLastHeartbeatAt = continuousPlayStartedAt; + continuousPlayAccumulatedMs = 0; + continuousPlayLastHeartbeatAt = Date.now(); + continuousPlayActive = !document.hidden; if (global.world) { resetWorldProgress(global.world); global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0); @@ -2188,19 +3007,36 @@ } function openDialog() { - dom.dialog?.classList.remove("hidden"); + if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dom.dialog, dom.button); + else { + dom.dialog?.classList.remove("hidden"); + dom.dialog?.setAttribute("aria-hidden", "false"); + } render({ forceList: true }); - dom.closeIconButton?.focus?.(); + if (!global.TarinaiManagedDialogs?.open) dom.closeIconButton?.focus?.(); void syncPending({ full: true }); } function closeDialog() { - dom.dialog?.classList.add("hidden"); - dom.button?.focus?.(); + if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dom.dialog); + else { + dom.dialog?.classList.add("hidden"); + dom.dialog?.setAttribute("aria-hidden", "true"); + dom.button?.focus?.(); + } } document.addEventListener("visibilitychange", () => { - if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now(); + const now = Date.now(); + if (document.hidden) { + if (continuousPlayActive && now >= continuousPlayLastHeartbeatAt) { + continuousPlayAccumulatedMs += now - continuousPlayLastHeartbeatAt; + } + continuousPlayActive = false; + } else { + continuousPlayLastHeartbeatAt = now; + continuousPlayActive = true; + } }); if (typeof global.setInterval === "function") { global.setInterval(() => { @@ -2213,7 +3049,7 @@ } dom.button?.addEventListener("click", () => { - global.audio?.uiClick?.(); + global.TarinaiAudio?.uiClick?.(); openDialog(); }); dom.closeButton?.addEventListener("click", closeDialog); @@ -2225,19 +3061,15 @@ dom.dialog?.addEventListener("click", event => { if (event.target === dom.dialog) closeDialog(); }); - document.addEventListener("keydown", event => { - if (event.key === "Escape" && !dom.dialog?.classList.contains("hidden")) closeDialog(); + dom.dialog?.addEventListener("tarinai:dialog-close-request", closeDialog); + global.addEventListener?.("pagehide", () => { + saveState(); + }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) saveState(); }); - global.addEventListener?.("pagehide", saveState); const api = Object.freeze({ - definitions: DEFINITIONS, - categories: ACHIEVEMENT_CATEGORIES, - unlock, - open: openDialog, - close: closeDialog, - refresh() { return syncPending({ full: true }); }, - reset: resetAchievements, isUnlocked(id) { return Boolean(state.unlocked[String(id || "")]); }, recordBirth, recordDeath, @@ -2248,19 +3080,17 @@ recordConsumableUse, recordFirstAidRecovery, recordPlushiePokeFling, - evaluateStatusEffectAchievements, recordIgnition, recordIntervention, recordPlayerPlacement, recordPlayerDeletion, recordHistoryAction, recordRobotClean, + importWorldAchievementProgress, evaluateCleanFreak, recordFightStarted, recordShotFired, recordLoveMochiBirth, - evaluateMegalopolis, - recordHeldTarinai, recordFavoriteTarinai, recordStatsButtonPress, recordPushNotificationsEnabled, @@ -2269,7 +3099,6 @@ recordLinkPlaced, recordSignalActivation, recordAntPopulation, - evaluateAntNestWithoutTarinai, recordAntKilled, recordStickyBombPass, recordCircuitBoardSignalTransfer, @@ -2278,14 +3107,8 @@ recordSaveSlotsFilled, recordEmptyClick, recordGroundChange, - evaluateSandbox, - recordLowFps, - recordContinuousPlay, - recordDailyPlay, - recordBelowAbsoluteZeroItem, recordDamageSource, recordAntDamageSource, - debugUnlockAll: unlockAllAchievementsForDebug, exportSpellState, importSpellState, evaluateWorld, @@ -2295,9 +3118,39 @@ recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); }, }); global.TarinaiAchievements = api; - recordDailyPlay({ source: "load" }); - if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" }); - render(); - global.setTimeout(() => recordSaveSlotsFilled(), 0); - global.setTimeout(() => void syncPending({ full: true }), 700); + if (global.__TARINAI_TEST__) { + global.TarinaiAchievementTestHooks = Object.freeze({ + state() { return normalizeState(state); }, + growOnlyStateDelta, + mergeStateData, + blankState, + normalizeState, + progressCounter, + localProgressSnapshot, + recordFightPairDangerDeath, + recordNaturalGeneration, + definitions: DEFINITIONS, + categories: ACHIEVEMENT_CATEGORIES, + definition(id) { return definitionById.get(String(id || "")) || null; }, + refresh() { return syncPending({ full: true }); }, + reset: resetAchievements, + open: openDialog, + render(options = {}) { return render(options); }, + }); + } + setupPersistenceCrossTabSync(); + if (dom.buttonCount) dom.buttonCount.textContent = "…"; + if (dom.dialogCount) dom.dialogCount.textContent = `… / ${DEFINITIONS.length}`; + persistenceReadyPromise = hydrateDurableState().catch(() => { + persistenceHydrated = true; + persistStateNow(); + return true; + }).then(() => { + recordDailyPlay({ source: "load" }); + if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" }); + render(); + global.setTimeout(() => recordSaveSlotsFilled(), 0); + global.setTimeout(() => void syncPending({ full: true }), 700); + return true; + }); })(window); diff --git a/js/circuit_board_system.js b/js/circuit_board_system.js index 1e49b9a..2131fb2 100644 --- a/js/circuit_board_system.js +++ b/js/circuit_board_system.js @@ -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 = `