This commit is contained in:
33333-33333 2026-07-16 22:12:03 +09:00
commit 2cc2f003df
71 changed files with 3359 additions and 1027 deletions

View file

@ -1,6 +0,0 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule mod_access_compat.c>
Deny from all
</IfModule>

View file

@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.16.29`.
- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.74`.
- 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`.
@ -82,31 +82,24 @@ This section records current boundaries only. Version-by-version cleanup history
- 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.
## v39.16.30 routing notes
## Current Feature Routing
- War preset composition: `js/world_reset_presets.js`.
- Fence restitution: `js/world_environment.js`; ball response: `js/item_dynamic_ball_system.js`.
- Ball-to-Tarinai contact, including sleepers: `js/collision_response_system.js`.
- Movable placement stacking: `js/item_tool_metadata.js`, `js/collision_footprint_system.js`, `js/world_placement_log.js`.
- Delete-tool target lookup: `js/world_tool_actions.js`.
- Nest-box tooltip offset: `js/render.js`.
- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.74`.
- Achievement 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`.
## v39.16.31 routing notes
## Cleanup Candidate Audit - resolved
- Hair Trigger preset: `js/world_reset_presets.js`; physics tool icon renderer: `js/ui_tools.js`; world tooltip offset: `js/render.js`.
The previous cleanup candidate queue has been applied or adjusted. Current state:
## Achievement system (v39.16.60)
- `js/achievements.js`: 54 definitions, local unlock state, persistent placement/ant-kill/reproduction/fight-mochi/shot/love-mochi/Undo/Redo/quick-delete progress, field-scoped direct-feed/cleaner/self-sufficiency/enemy-ant progress, individual care/hold/temperature/fight markers, allowlisted inline disclosures and local progress, shared-rate bars/counts, event-driven generation/status-effect/first-aid/plushie checks, completionist chaining, cached hidden-dialog rendering, debounced pending synchronization, and periodic additive full-state recovery synchronization.
- `achievement_api.php`: validates current IDs, reads legacy verbose or compact v2 aggregate state, writes compact indexed arrays, migrates the intersection of the retired automation/link achievements into `mechanized_industry`, accepts idempotent bulk unlock synchronization, and returns anonymous global percentages plus achiever counts.
- `css/components.css`: achievement rows use `--achievement-rate` to render horizontal background fills; unlocked rows use a distinct fill.
- `js/world.js`, `js/snapshot_system.js`, and `js/save_codec.js`: field-local achievement counters and per-entity/item markers reset with a new field and persist within current-format saves; schema 43 is current; compact schema 42 and legacy JSON schema 41 remain readable for migration.
- Hooks span placement/deletion/history, robot cleaning, direct care and consumables, fight starts/deaths, ant damage attribution, save slots, pause input, hold state, population/temperature/weather evaluation, first-aid recovery, shared poke targeting/highlights, plushie fling, and nest-box/pipe weather blocking.
- Audits: `scripts/achievement_audit.js` checks legacy/current boundaries and recovery; `scripts/playstyle_achievement_audit.js` checks the play-style conditions, reset scopes, false-positive guards, tooltips, and production hooks; `scripts/achievement_integration_audit.py` checks IDs and integration wiring.
### Achievement spell payload (v39.16.60)
- `TarinaiAchievements.exportSpellState()` emits a compact array: unlock bitset, delta-coded timestamps, and packed persistent counters.
- `snapshot_system.js` includes it as `snapshot.g.s` only for exported spells and merges it through `importSpellState()` on load.
- `save_system.js` requests achievement inclusion for spell export; internal save slots do not include global achievement state.
- `save_codec.js` schema 42 writes achievement metadata directly as sparse binary fields instead of a JSON string and can still decode schema 41 spell saves.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.
- Version/docs routing was aligned to `39.16.74`; `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.

View file

@ -5,8 +5,8 @@ tarinai_compact_save_stage4
- 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 41 is accepted for legacy v39.16.57 spells; new exports use schema 42.
- Version: 39.16.60
- Binary schema 46 is the only accepted save/spell format; it stores the current achievement mask and sticky-bomb pass count.
- Version: 39.16.74
- 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.
@ -90,3 +90,90 @@ tarinai_compact_save_stage4
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.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 4143.
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.
## v39.16.64
- 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
- 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.66
- 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
- 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
- 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
- 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.70
- 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.71
- 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.72
- 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.73
- 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.74
- 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.

View file

@ -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.60",
"version": "39.16.74",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",
@ -53,19 +53,28 @@
{
"id": "scripts",
"patterns": [
"scripts/*.js",
"scripts/*.py",
"scripts/*.ps1"
],
"meaning": "Generators, regression checks, and token-aware AI inventory tooling."
"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",
@ -123,7 +132,8 @@
"js/item*.js",
"js/items.js",
"js/structures.js",
"js/structure_lifecycle.js"
"js/structure_lifecycle.js",
"js/circuit_board_system.js"
],
"meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering."
},
@ -131,7 +141,8 @@
"id": "tarinai",
"patterns": [
"js/tarinai*.js",
"js/ants.js"
"js/ants.js",
"js/seesaw_system.js"
],
"meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model."
},

View file

@ -38,7 +38,9 @@ Fences block movement and can be rotated for layout control. Gates can be opened
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.
Wires and insulated wires carry signals between compatible items. Normal energized wire can hurt living targets on contact, while insulated wire is safer for incidental contact. Detectors can sense targets or timing conditions and send signals, allowing simple automation.
## 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
@ -73,8 +75,8 @@ 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.60)
- Fifty-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, and ant-related play styles.
## Achievement system (v39.16.74)
- 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.
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0100%, and the global achiever count is displayed beside it.
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
@ -83,12 +85,15 @@ Display settings control effects, shadows, drawing detail, and quality choices f
- 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 雨宿り完了.
- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v2 achievement aggregate JSON while continuing to read the earlier verbose state format.
- 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 (v39.16.60)
### Achievement spell persistence (v39.16.74)
- 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. Legacy JSON-metadata spells remain readable.
- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Only the current schema-49 save/spell format and achievement payload v5 are accepted.
- 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.

View file

@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.60`.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.74`.
- v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons.
- v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans.
- v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed.
@ -72,14 +72,14 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
## Achievement system (v39.16.60)
- Fifty-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, and ant-related play styles.
## Achievement system (v39.16.74)
- 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.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.
- 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 v2 arrays, with transparent reading/migration of the prior verbose format, and is protected by Apache rules.
- 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
@ -87,6 +87,25 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
- 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 fifty-one 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.
- 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 while legacy v39.16.57 spell data remains readable.
- 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.
- `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 4143.
- 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.74: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire.

View file

@ -60,9 +60,22 @@ const ACHIEVEMENT_IDS = [
'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',
];
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;
function respond(array $payload, int $status = 200): never {
http_response_code($status);
@ -78,16 +91,83 @@ function empty_state(): array {
return ['players' => [], 'unlocks' => []];
}
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 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 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';
}
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 = [];
if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) {
// 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 = $row[0];
$id = strtolower($row[0]);
$playerIds[] = $id;
$players[$id] = [
'firstSeen' => max(1, (int)($row[1] ?? 1)),
@ -96,7 +176,7 @@ function normalize_state(mixed $value): array {
];
}
foreach ($value['u'] as $achievementId => $flat) {
if (!is_string($achievementId) || !is_array($flat)) continue;
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue;
$records = [];
$count = count($flat);
for ($i = 0; $i + 1 < $count; $i += 2) {
@ -107,8 +187,24 @@ function normalize_state(mixed $value): array {
if ($records) $unlocks[$achievementId] = $records;
}
} else {
$players = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
$unlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
$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) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], 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);
}
}
}
$legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first'])
@ -133,30 +229,112 @@ 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;
}
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);
});
$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] : [];
$players[] = [
$id,
max(1, (int)($player['firstSeen'] ?? 1)),
max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))),
substr((string)($player['gameVersion'] ?? ''), 0, 32),
];
$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;
}
$unlocks = [];
foreach ($state['unlocks'] as $achievementId => $records) {
if (!is_string($achievementId) || !is_array($records) || !$records) continue;
$flat = [];
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;
$flat[] = $playerIndex[$id];
$flat[] = max(1, (int)$timestamp);
$pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime];
}
if ($flat) $unlocks[$achievementId] = $flat;
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;
}
return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];
}
function encode_compact_state(array $state): string {
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);
}
}
function decode_stored_state(string $contents): array {
if (trim($contents) === '') return [empty_state(), 'empty'];
try {
$decoded = json_decode($contents, true, 512, 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];
}
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');
}
try {
$length = strlen($encoded);
$offset = 0;
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');
} catch (Throwable $error) {
fclose($handle);
@unlink($tempPath);
throw $error;
}
fclose($handle);
@chmod($tempPath, 0664);
if (!@rename($tempPath, $dataPath)) {
@unlink($tempPath);
throw new RuntimeException('storage_write_failed');
}
ksort($unlocks, SORT_STRING);
return ['v' => 2, 'p' => $players, 'u' => $unlocks];
}
function build_summary(array $state): array {
@ -180,10 +358,19 @@ function build_summary(array $state): array {
}
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
$payload = [];
if ($method === 'POST') {
$raw = file_get_contents('php://input');
$decoded = json_decode(is_string($raw) ? $raw : '', true);
$declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0));
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);
}
if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400);
$payload = $decoded;
}
@ -198,9 +385,13 @@ $playerId = $method === 'POST'
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') {
@ -227,11 +418,12 @@ if ($action === 'unlock') {
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
}
$numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT);
if ($numericTimestamp === false || $numericTimestamp <= 0) {
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;
@ -239,23 +431,31 @@ 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;
$handle = fopen($dataPath, 'c+');
if ($handle === false || !flock($handle, LOCK_EX)) {
if (is_resource($handle)) fclose($handle);
$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 {
rewind($handle);
$contents = stream_get_contents($handle);
$state = normalize_state(json_decode(is_string($contents) ? $contents : '', true));
$contents = is_file($dataPath) ? file_get_contents($dataPath) : '';
if ($contents === false) throw new RuntimeException('storage_unavailable');
[$state, $sourceFormat] = decode_stored_state($contents);
$now = time();
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
? $state['players'][$playerId]
: ['firstSeen' => $now];
$player['lastSeen'] = $now;
$player['gameVersion'] = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
$state['players'][$playerId] = $player;
$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'];
@ -285,14 +485,18 @@ try {
}
}
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
fflush($handle);
$encoded = encode_compact_state($state);
if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) {
atomic_write_state($dataPath, $encoded);
}
$summary = build_summary($state);
} 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($handle, LOCK_UN);
fclose($handle);
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
respond($summary);
if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503);
respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503);

View file

@ -1,5 +1,5 @@
{
"version": "39.16.60",
"version": "39.16.74",
"css": [
"css/base.css",
"css/layout.css",
@ -36,6 +36,7 @@
"js/constraint_system.js",
"js/physics_world_system.js",
"js/physics_projection_system.js",
"js/circuit_board_system.js",
"js/signal_system.js",
"js/assets.js",
"js/audio.js",
@ -88,6 +89,7 @@
"js/tarinai_consumable_behavior.js",
"js/tarinai_social_action_runtime.js",
"js/tarinai_building_behavior.js",
"js/seesaw_system.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_food_prototype_mixin.js",
@ -147,8 +149,8 @@
"js/ui.js",
"js/game_dialogs.js",
"js/achievements.js",
"js/ui_log.js",
"js/ui_helpers.js",
"js/ui_log.js",
"js/ui_selected.js",
"js/save_schema.js",
"js/snapshot_system.js",

View file

@ -917,3 +917,125 @@ h1 {
.save-confirm-dialog { z-index: 36; }
.save-confirm-panel { width: min(390px, 100%); }
.save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; }
/* Logic-board editor */
.circuit-board-editor{position:fixed;inset:0;z-index:1200;display:grid;place-items:center;padding:18px;background:rgba(18,22,27,.58);backdrop-filter:blur(3px)}
.circuit-board-editor.hidden{display:none}
.circuit-board-panel{width:min(980px,96vw);max-height:92vh;overflow:hidden;background:rgba(250,249,244,.98);color:#2e3531;border:1px solid rgba(44,74,57,.35);border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.35);display:flex;flex-direction:column}
.circuit-board-titlebar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:linear-gradient(135deg,#235b3a,#347b50);color:white}
.circuit-board-titlebar h2{margin:0;font-size:1.18rem}
.circuit-board-titlebar button{border:0;background:transparent;color:white;font-size:1.7rem;line-height:1;cursor:pointer}
.circuit-board-help{margin:12px 18px 6px;font-size:.88rem;color:#536059}
.circuit-board-layout{display:grid;grid-template-columns:minmax(280px,42%) minmax(360px,58%);gap:14px;padding:10px 18px;min-height:0;flex:1}
.circuit-board-preview{align-self:start;background:#17231c;border-radius:14px;padding:10px;min-height:250px;position:sticky;top:0}
.circuit-board-preview svg{width:100%;height:auto;display:block}
.circuit-preview-lines path{fill:none;stroke:#d7b84d;stroke-width:2.4}
.circuit-preview-gates rect{fill:#26352d;stroke:#d9e9dd;stroke-width:1.3}.circuit-preview-gates text{fill:#f4f7f2;text-anchor:middle;font:700 11px sans-serif}
.circuit-preview-pins circle{fill:#73c8ff;stroke:#f4f1d6;stroke-width:1.2}.circuit-preview-pins circle:nth-of-type(n+4){fill:#ff9a60}.circuit-preview-pins text{fill:#eef4ef;font:700 11px sans-serif}
.circuit-board-scroll{overflow:auto;padding-right:4px}.circuit-board-scroll section{margin:0 0 14px;padding:12px;border:1px solid rgba(45,83,60,.18);border-radius:12px;background:white}.circuit-board-scroll h3{margin:0 0 10px;font-size:.94rem}
.circuit-port-row,.circuit-gate-row,.circuit-output-row{display:grid;grid-template-columns:minmax(72px,1fr) auto auto auto;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(0,0,0,.07)}
.circuit-port-row:last-child,.circuit-gate-row:last-child,.circuit-output-row:last-child{border-bottom:0}.circuit-port-row label,.circuit-gate-row label{display:flex;align-items:center;gap:4px;font-size:.8rem}
.circuit-board-panel select,.circuit-board-panel button{font:inherit}.circuit-board-panel select{padding:5px 7px;border:1px solid rgba(37,77,53,.27);border-radius:7px;background:white}
.circuit-gate-actions{display:flex;gap:7px;margin-bottom:8px}.circuit-gate-actions button,.circuit-gate-row button,.circuit-board-actions button{padding:7px 12px;border:1px solid rgba(38,82,54,.26);border-radius:9px;background:#f1f5f1;cursor:pointer}.circuit-gate-row button{padding:4px 9px;color:#a13f3f}
.circuit-empty{margin:6px 0;color:#7a837d;font-size:.84rem}.circuit-output-row{grid-template-columns:50px 1fr}.circuit-board-actions{display:flex;justify-content:flex-end;gap:9px;padding:12px 18px 16px;border-top:1px solid rgba(0,0,0,.09)}.circuit-board-actions .primary{background:#2e754b;color:white;border-color:#245f3d}
@media(max-width:760px){.circuit-board-layout{grid-template-columns:1fr}.circuit-board-preview{position:relative;min-height:180px}.circuit-port-row,.circuit-gate-row{grid-template-columns:1fr 1fr}.circuit-gate-row button{justify-self:end}.circuit-board-panel{max-height:96vh}}
/* Grid circuit editor reuses the mechanical editor shell and toolbar. */
.circuit-grid-panel { width: min(930px, calc(100vw - 28px)); }
.circuit-grid-tools { max-height: none; overflow: visible; }
.circuit-grid-canvas-wrap {
max-width: 100%;
max-height: min(62vh, 560px);
overflow: auto;
padding: 6px;
border-radius: 12px;
background: rgba(20,35,27,0.35);
}
#circuitGridCanvas {
width: auto;
height: auto;
max-width: none;
display: block;
border-radius: 10px;
border: 1px solid rgba(84,68,48,0.20);
background: #14231b;
touch-action: none;
cursor: crosshair;
}
.circuit-grid-hint { line-height: 1.55; }
@media (max-width: 560px) {
.circuit-grid-panel { width: calc(100vw - 16px); }
.circuit-grid-tools { max-height: 20vh; overflow-y: auto; }
.circuit-grid-canvas-wrap { max-height: min(48vh, 360px); }
#circuitGridCanvas { max-width: none; }
}
/* v39.16.69: circuit terminal count controls and detector numeric range inputs. */
.circuit-port-count-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0 0 10px;
}
.circuit-port-count-controls label {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid rgba(84,68,48,0.14);
border-radius: 10px;
background: rgba(255,255,255,0.56);
font-size: 13px;
font-weight: 700;
}
.circuit-port-count-controls button {
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgba(38,82,54,.26);
border-radius: 8px;
background: #f1f5f1;
cursor: pointer;
}
.circuit-port-count-controls input {
width: 54px;
min-height: 30px;
padding: 3px 6px;
border: 1px solid rgba(37,77,53,.27);
border-radius: 7px;
background: white;
text-align: center;
font: inherit;
font-variant-numeric: tabular-nums;
}
.pressure-number-inputs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.pressure-number-inputs label {
display: grid;
grid-template-columns: auto minmax(72px, 1fr);
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 700;
}
.pressure-number-inputs input {
width: 100%;
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(42,121,172,0.28);
border-radius: 8px;
background: rgba(255,255,255,0.92);
color: var(--ink);
font: inherit;
font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
.circuit-port-count-controls { display: grid; grid-template-columns: 1fr; }
.circuit-port-count-controls label { justify-content: space-between; }
}
/* v39.16.70: resizable circuit board and movable junctions. */
.circuit-port-count-controls label:nth-child(-n+2){background:rgba(236,246,239,.82)}

View file

@ -1128,7 +1128,7 @@
margin-top: 4px;
color: #526b82;
font-size: 10px;
font-weight: 800;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.achievement-entry-meta {

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.60" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.60" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.60" />
<link rel="stylesheet" href="css/base.css?v=39.16.60" />
<link rel="stylesheet" href="css/layout.css?v=39.16.60" />
<link rel="stylesheet" href="css/panel.css?v=39.16.60" />
<link rel="stylesheet" href="css/components.css?v=39.16.60" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.60" />
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.74" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.74" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.74" />
<link rel="stylesheet" href="css/base.css?v=39.16.74" />
<link rel="stylesheet" href="css/layout.css?v=39.16.74" />
<link rel="stylesheet" href="css/panel.css?v=39.16.74" />
<link rel="stylesheet" href="css/components.css?v=39.16.74" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.74" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -42,7 +42,7 @@
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<button id="saveBtn" class="btn">&#x30BB;&#x30FC;&#x30D6;</button>
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">&#x5B9F;&#x7E3E; <span id="achievementButtonCount">0/51</span></button>
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">&#x5B9F;&#x7E3E; <span id="achievementButtonCount">0/64</span></button>
<button id="resetBtn" class="btn danger reset-top-btn">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
<div id="topMenu" class="top-menu">
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">&#x2630;</button>
@ -202,7 +202,7 @@
<div class="achievement-dialog-head">
<div>
<div class="achievement-dialog-kicker">&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</div>
<h2 id="achievementsDialogTitle">&#x5B9F;&#x7E3E; <span id="achievementDialogCount">0 / 51</span></h2>
<h2 id="achievementsDialogTitle">&#x5B9F;&#x7E3E; <span id="achievementDialogCount">0 / 64</span></h2>
</div>
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="&#x5B9F;&#x7E3E;&#x3092;&#x9589;&#x3058;&#x308B;">&times;</button>
</div>
@ -292,171 +292,173 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.16.60" defer></script>
<script src="js/event_bus.js?v=39.16.60" defer></script>
<script src="js/domain_ids.js?v=39.16.60" defer></script>
<script src="js/registry_base.js?v=39.16.60" defer></script>
<script src="js/disease_registry.js?v=39.16.60" defer></script>
<script src="js/sound_pack.js?v=39.16.60" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.60" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.60" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.60" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.60" defer></script>
<script src="js/item_food_definitions.js?v=39.16.60" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.60" defer></script>
<script src="js/item_registry.js?v=39.16.60" defer></script>
<script src="js/data.js?v=39.16.60" defer></script>
<script src="js/ground_types.js?v=39.16.60" defer></script>
<script src="js/input_mode_manager.js?v=39.16.60" defer></script>
<script src="js/math.js?v=39.16.60" defer></script>
<script src="js/geometry_helpers.js?v=39.16.60" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.60" defer></script>
<script src="js/physics_helpers.js?v=39.16.60" defer></script>
<script src="js/placement_preview_system.js?v=39.16.60" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.60" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.60" defer></script>
<script src="js/mechanical_system.js?v=39.16.60" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.60" defer></script>
<script src="js/constraint_system.js?v=39.16.60" defer></script>
<script src="js/physics_world_system.js?v=39.16.60" defer></script>
<script src="js/physics_projection_system.js?v=39.16.60" defer></script>
<script src="js/signal_system.js?v=39.16.60" defer></script>
<script src="js/assets.js?v=39.16.60" defer></script>
<script src="js/audio.js?v=39.16.60" defer></script>
<script src="js/perf_profiler.js?v=39.16.60" defer></script>
<script src="js/display_helpers.js?v=39.16.60" defer></script>
<script src="js/render.js?v=39.16.60" defer></script>
<script src="js/sim_core.js?v=39.16.60" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.60" defer></script>
<script src="js/structures.js?v=39.16.60" defer></script>
<script src="js/item_type_catalog.js?v=39.16.60" defer></script>
<script src="js/items.js?v=39.16.60" defer></script>
<script src="js/item_type_initializers.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.60" defer></script>
<script src="js/item_update_policy.js?v=39.16.60" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.60" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.60" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.60" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.60" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.60" defer></script>
<script src="js/item_runtime.js?v=39.16.60" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.60" defer></script>
<script src="js/item_render_helpers.js?v=39.16.60" defer></script>
<script src="js/item_render_runtime.js?v=39.16.60" defer></script>
<script src="js/burn_motion_util.js?v=39.16.60" defer></script>
<script src="js/ants.js?v=39.16.60" defer></script>
<script src="js/health.js?v=39.16.60" defer></script>
<script src="js/tarinai.js?v=39.16.60" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.60" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.60" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.60" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.60" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.60" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.60" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.60" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.60" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.60" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.60" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.60" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.60" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.60" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.60" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.60" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.60" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.60" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.60" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.60" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.60" defer></script>
<script src="js/tarinai_render.js?v=39.16.60" defer></script>
<script src="js/world.js?v=39.16.60" defer></script>
<script src="js/world_view.js?v=39.16.60" defer></script>
<script src="js/family_graph.js?v=39.16.60" defer></script>
<script src="js/world_reset_presets.js?v=39.16.60" defer></script>
<script src="js/world_family_social.js?v=39.16.60" defer></script>
<script src="js/impact_core_system.js?v=39.16.60" defer></script>
<script src="js/impact_response_system.js?v=39.16.60" defer></script>
<script src="js/collision_response_system.js?v=39.16.60" defer></script>
<script src="js/world_combat_effects.js?v=39.16.60" defer></script>
<script src="js/world_environment.js?v=39.16.60" defer></script>
<script src="js/world_temperature_system.js?v=39.16.60" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.60" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.60" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.60" defer></script>
<script src="js/world_ants_system.js?v=39.16.60" defer></script>
<script src="js/weather_system.js?v=39.16.60" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.60" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.60" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.60" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.60" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.60" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.60" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.60" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.60" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.60" defer></script>
<script src="js/simulation_systems.js?v=39.16.60" defer></script>
<script src="js/system_order.js?v=39.16.60" defer></script>
<script src="js/simulation.js?v=39.16.60" defer></script>
<script src="js/colony_situation_system.js?v=39.16.60" defer></script>
<script src="js/world_update.js?v=39.16.60" defer></script>
<script src="js/world_tool_actions.js?v=39.16.60" defer></script>
<script src="js/world_placement_log.js?v=39.16.60" defer></script>
<script src="js/world_event_effects.js?v=39.16.60" defer></script>
<script src="js/command_dispatcher.js?v=39.16.60" defer></script>
<script src="js/text_catalog.js?v=39.16.60" defer></script>
<script src="js/ui.js?v=39.16.60" defer></script>
<script src="js/game_dialogs.js?v=39.16.60" defer></script>
<script src="js/achievements.js?v=39.16.60" defer></script>
<script src="js/ui_log.js?v=39.16.60" defer></script>
<script src="js/ui_helpers.js?v=39.16.60" defer></script>
<script src="js/ui_selected.js?v=39.16.60" defer></script>
<script src="js/save_schema.js?v=39.16.60" defer></script>
<script src="js/snapshot_system.js?v=39.16.60" defer></script>
<script src="js/restore_coordinator.js?v=39.16.60" defer></script>
<script src="js/history_system.js?v=39.16.60" defer></script>
<script src="js/save_codec.js?v=39.16.60" defer></script>
<script src="js/save_storage.js?v=39.16.60" defer></script>
<script src="js/save_system.js?v=39.16.60" defer></script>
<script src="js/ui_tooltips.js?v=39.16.60" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.60" defer></script>
<script src="js/ui_ground.js?v=39.16.60" defer></script>
<script src="js/ui_tools.js?v=39.16.60" defer></script>
<script src="js/ui_input_shared.js?v=39.16.60" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.60" defer></script>
<script src="js/ui_input_touch.js?v=39.16.60" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.60" defer></script>
<script src="js/ui_bind.js?v=39.16.60" defer></script>
<script src="js/ui_charts.js?v=39.16.60" defer></script>
<script src="js/ui_family_data.js?v=39.16.60" defer></script>
<script src="js/ui_family_async.js?v=39.16.60" defer></script>
<script src="js/ui_family_layout.js?v=39.16.60" defer></script>
<script src="js/ui_family_paths.js?v=39.16.60" defer></script>
<script src="js/ui_family_render.js?v=39.16.60" defer></script>
<script src="js/main.js?v=39.16.60" defer></script>
<script src="js/debug_tools.js?v=39.16.60" defer></script>
<script src="js/version.js?v=39.16.74" defer></script>
<script src="js/event_bus.js?v=39.16.74" defer></script>
<script src="js/domain_ids.js?v=39.16.74" defer></script>
<script src="js/registry_base.js?v=39.16.74" defer></script>
<script src="js/disease_registry.js?v=39.16.74" defer></script>
<script src="js/sound_pack.js?v=39.16.74" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.74" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.74" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.74" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.74" defer></script>
<script src="js/item_food_definitions.js?v=39.16.74" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.74" defer></script>
<script src="js/item_registry.js?v=39.16.74" defer></script>
<script src="js/data.js?v=39.16.74" defer></script>
<script src="js/ground_types.js?v=39.16.74" defer></script>
<script src="js/input_mode_manager.js?v=39.16.74" defer></script>
<script src="js/math.js?v=39.16.74" defer></script>
<script src="js/geometry_helpers.js?v=39.16.74" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.74" defer></script>
<script src="js/physics_helpers.js?v=39.16.74" defer></script>
<script src="js/placement_preview_system.js?v=39.16.74" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.74" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.74" defer></script>
<script src="js/mechanical_system.js?v=39.16.74" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.74" defer></script>
<script src="js/constraint_system.js?v=39.16.74" defer></script>
<script src="js/physics_world_system.js?v=39.16.74" defer></script>
<script src="js/physics_projection_system.js?v=39.16.74" defer></script>
<script src="js/circuit_board_system.js?v=39.16.74" defer></script>
<script src="js/signal_system.js?v=39.16.74" defer></script>
<script src="js/assets.js?v=39.16.74" defer></script>
<script src="js/audio.js?v=39.16.74" defer></script>
<script src="js/perf_profiler.js?v=39.16.74" defer></script>
<script src="js/display_helpers.js?v=39.16.74" defer></script>
<script src="js/render.js?v=39.16.74" defer></script>
<script src="js/sim_core.js?v=39.16.74" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.74" defer></script>
<script src="js/structures.js?v=39.16.74" defer></script>
<script src="js/item_type_catalog.js?v=39.16.74" defer></script>
<script src="js/items.js?v=39.16.74" defer></script>
<script src="js/item_type_initializers.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.74" defer></script>
<script src="js/item_update_policy.js?v=39.16.74" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.74" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.74" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.74" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.74" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.74" defer></script>
<script src="js/item_runtime.js?v=39.16.74" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.74" defer></script>
<script src="js/item_render_helpers.js?v=39.16.74" defer></script>
<script src="js/item_render_runtime.js?v=39.16.74" defer></script>
<script src="js/burn_motion_util.js?v=39.16.74" defer></script>
<script src="js/ants.js?v=39.16.74" defer></script>
<script src="js/health.js?v=39.16.74" defer></script>
<script src="js/tarinai.js?v=39.16.74" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.74" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.74" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.74" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.74" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.74" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.74" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.74" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.74" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.74" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.74" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.74" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.74" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.74" defer></script>
<script src="js/seesaw_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.74" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.74" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.74" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.74" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.74" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.74" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.74" defer></script>
<script src="js/tarinai_render.js?v=39.16.74" defer></script>
<script src="js/world.js?v=39.16.74" defer></script>
<script src="js/world_view.js?v=39.16.74" defer></script>
<script src="js/family_graph.js?v=39.16.74" defer></script>
<script src="js/world_reset_presets.js?v=39.16.74" defer></script>
<script src="js/world_family_social.js?v=39.16.74" defer></script>
<script src="js/impact_core_system.js?v=39.16.74" defer></script>
<script src="js/impact_response_system.js?v=39.16.74" defer></script>
<script src="js/collision_response_system.js?v=39.16.74" defer></script>
<script src="js/world_combat_effects.js?v=39.16.74" defer></script>
<script src="js/world_environment.js?v=39.16.74" defer></script>
<script src="js/world_temperature_system.js?v=39.16.74" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.74" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.74" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.74" defer></script>
<script src="js/world_ants_system.js?v=39.16.74" defer></script>
<script src="js/weather_system.js?v=39.16.74" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.74" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.74" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.74" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.74" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.74" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.74" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.74" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.74" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.74" defer></script>
<script src="js/simulation_systems.js?v=39.16.74" defer></script>
<script src="js/system_order.js?v=39.16.74" defer></script>
<script src="js/simulation.js?v=39.16.74" defer></script>
<script src="js/colony_situation_system.js?v=39.16.74" defer></script>
<script src="js/world_update.js?v=39.16.74" defer></script>
<script src="js/world_tool_actions.js?v=39.16.74" defer></script>
<script src="js/world_placement_log.js?v=39.16.74" defer></script>
<script src="js/world_event_effects.js?v=39.16.74" defer></script>
<script src="js/command_dispatcher.js?v=39.16.74" defer></script>
<script src="js/text_catalog.js?v=39.16.74" defer></script>
<script src="js/ui.js?v=39.16.74" defer></script>
<script src="js/game_dialogs.js?v=39.16.74" defer></script>
<script src="js/achievements.js?v=39.16.74" defer></script>
<script src="js/ui_helpers.js?v=39.16.74" defer></script>
<script src="js/ui_log.js?v=39.16.74" defer></script>
<script src="js/ui_selected.js?v=39.16.74" defer></script>
<script src="js/save_schema.js?v=39.16.74" defer></script>
<script src="js/snapshot_system.js?v=39.16.74" defer></script>
<script src="js/restore_coordinator.js?v=39.16.74" defer></script>
<script src="js/history_system.js?v=39.16.74" defer></script>
<script src="js/save_codec.js?v=39.16.74" defer></script>
<script src="js/save_storage.js?v=39.16.74" defer></script>
<script src="js/save_system.js?v=39.16.74" defer></script>
<script src="js/ui_tooltips.js?v=39.16.74" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.74" defer></script>
<script src="js/ui_ground.js?v=39.16.74" defer></script>
<script src="js/ui_tools.js?v=39.16.74" defer></script>
<script src="js/ui_input_shared.js?v=39.16.74" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.74" defer></script>
<script src="js/ui_input_touch.js?v=39.16.74" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.74" defer></script>
<script src="js/ui_bind.js?v=39.16.74" defer></script>
<script src="js/ui_charts.js?v=39.16.74" defer></script>
<script src="js/ui_family_data.js?v=39.16.74" defer></script>
<script src="js/ui_family_async.js?v=39.16.74" defer></script>
<script src="js/ui_family_layout.js?v=39.16.74" defer></script>
<script src="js/ui_family_paths.js?v=39.16.74" defer></script>
<script src="js/ui_family_render.js?v=39.16.74" defer></script>
<script src="js/main.js?v=39.16.74" defer></script>
<script src="js/debug_tools.js?v=39.16.74" defer></script>
</body>
</html>

View file

@ -4,24 +4,26 @@
const STORAGE_KEY = "tarinai_achievements_v2";
const PLAYER_KEY = "tarinai_achievement_player_v1";
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.60");
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.74");
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 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 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 = 0x3f;
const SYNC_DEBOUNCE_MS = 450;
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
const SPELL_STATE_VERSION = 3;
const PREVIOUS_SPELL_STATE_VERSION = 2;
const LEGACY_SPELL_STATE_VERSION = 1;
const SPELL_STATE_VERSION = 5;
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 PRE_UNLOCK_DESCRIPTION_IDS = new Set([
@ -41,6 +43,8 @@
"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",
@ -48,6 +52,7 @@
"town_doctor_50",
"chaos_seeker_666_fights",
"fertility_seeker_721_love_births",
"daily_play_7_days",
]);
const ACHIEVEMENT_CATEGORIES = Object.freeze([
Object.freeze({
@ -56,6 +61,7 @@
ids: Object.freeze([
"first_birth", "natural_zunchi_slave", "natural_tarinai_king",
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
"ant_nest_without_tarinai",
"birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
@ -72,15 +78,16 @@
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
"medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
"revolution", "fuel_to_fire", "king_full_satisfaction", "slave_zero_satisfaction",
"chaos_seeker_666_fights",
"chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai",
]),
}),
Object.freeze({
id: "construction",
title: "\u5efa\u7bc9",
ids: Object.freeze([
"placed_objects_100", "mechanized_industry", "robot_cleaner_100",
"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({
@ -88,8 +95,9 @@
title: "\u305d\u306e\u4ed6",
ids: Object.freeze([
"idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second",
"continuous_play_1_hour", "undo_mass_revival", "held_30_seconds",
"undo_20", "redo_20", "poke_plushie_fling", "true_tarinai_observer", "sniper_333_shots",
"undo_mass_revival", "held_30_seconds", "undo_20", "redo_20",
"poke_plushie_fling", "sniper_333_shots", "low_fps_single_digit",
"daily_play_7_days", "continuous_play_24_hours", "continuous_play_1_hour", "true_tarinai_observer",
]),
}),
]);
@ -111,14 +119,14 @@
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: "\u30b2\u30fc\u30e0\u51855\u5206\u9593\u3001\u914d\u7f6e\u30fb\u524a\u9664\u30fb\u3064\u3064\u304f\u30fb\u3064\u307e\u3080\u30fb\u5c04\u6483\u3092\u884c\u308f\u306a\u3044\u3002" }),
Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "5\u5206\u9593\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\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\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: "\u4e00\u6642\u505c\u6b62\u30dc\u30bf\u30f3\u30921\u79d2\u9593\u306b4\u56de\u9023\u6253\u3059\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" }),
@ -128,7 +136,7 @@
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: "\u7d76\u5bfe\u96f6\u5ea6\u3092\u4e0b\u56de\u308b\u6e29\u5ea6\u3092\u4f5c\u308b\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" }),
@ -148,6 +156,16 @@
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" }),
]);
const definitionById = new Map(DEFINITIONS.map(def => [def.id, def]));
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
@ -177,17 +195,19 @@
let requestSerial = 0;
let runtimeByWorld = new WeakMap();
let dangerFightDeathsByWorld = new WeakMap();
let wireShockTrackers = new WeakMap();
let syncQueue = Promise.resolve();
let toastQueue = [];
let toastActive = false;
let pauseClickTimes = [];
let continuousPlayStartedAt = document.hidden ? 0 : Date.now();
let emptyClickTimes = [];
let groundChangeTimes = [];
let continuousPlayStartedAt = Date.now();
let continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
let lastListSignature = "";
let scheduledSyncTimer = 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 } };
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 } };
}
function loadState() {
@ -235,6 +255,8 @@
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)),
};
const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]);
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
@ -316,10 +338,9 @@
}
function continuousPlayElapsedMs(now = Date.now()) {
if (state.unlocked.continuous_play_1_hour) return CONTINUOUS_PLAY_TARGET_MS;
if (state.unlocked.continuous_play_24_hours) return CONTINUOUS_PLAY_24H_TARGET_MS;
const timestamp = Number(now);
if (document.hidden || !continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0;
if (continuousPlayLastHeartbeatAt && timestamp - continuousPlayLastHeartbeatAt > 15000) return 0;
if (!continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0;
return Math.max(0, timestamp - continuousPlayStartedAt);
}
@ -351,10 +372,18 @@
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`;
}
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`;
}
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`;
}
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)));
@ -375,10 +404,13 @@
const local = [
Math.min(3333, Math.max(0, Number(state.progress?.birthCount || 0) || 0)),
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)),
Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)),
Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0),
].join(":");
return `${unlocks}|${rates}|${local}`;
}
@ -504,6 +536,10 @@
dom.list.replaceChildren(...groups);
}
function renderIfDialogOpen() {
if (!dom.dialog?.classList.contains("hidden")) render();
}
function resetUnlockToastVisual() {
if (!dom.toast) return;
dom.toast.classList.remove("absorbing");
@ -793,6 +829,8 @@
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));
return state.progress;
}
@ -855,35 +893,14 @@
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.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak),
state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay),
],
];
}
function parseSpellState(payload = null) {
if (!Array.isArray(payload)) return null;
const version = Number(payload[0]);
if (version === LEGACY_SPELL_STATE_VERSION) {
const maskHex = String(payload[1] || "").toLowerCase();
const expectedBytes = Math.ceil(DEFINITIONS.length / 8);
if (!/^[0-9a-f]*$/.test(maskHex) || maskHex.length !== expectedBytes * 2) {
throw new Error("invalid achievement spell mask");
}
const maskBytes = [];
for (let offset = 0; offset < maskHex.length; offset += 2) maskBytes.push(parseInt(maskHex.slice(offset, offset + 2), 16));
const unlockedIndexes = [];
for (let index = 0; index < DEFINITIONS.length; index += 1) {
if (maskBytes[Math.floor(index / 8)] & (1 << (index % 8))) unlockedIndexes.push(index);
}
return {
unlockedIndexes,
baseTimestamp: Math.max(0, Math.floor(Number(payload[2] || 0) || 0)),
deltas: Array.isArray(payload[3]) ? payload[3] : [],
packed: Array.isArray(payload[4]) ? payload[4] : [],
timestampUnitMs: 1000,
progressVersion: LEGACY_SPELL_STATE_VERSION,
};
}
if (version !== PREVIOUS_SPELL_STATE_VERSION && version !== SPELL_STATE_VERSION) return null;
if (!Array.isArray(payload) || Number(payload[0]) !== SPELL_STATE_VERSION) return null;
const lowWord = Number(payload[1]);
const highWord = Number(payload[2]);
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
@ -897,7 +914,7 @@
deltas: Array.isArray(payload[4]) ? payload[4] : [],
packed: Array.isArray(payload[5]) ? payload[5] : [],
timestampUnitMs: 60000,
progressVersion: version,
progressVersion: SPELL_STATE_VERSION,
};
}
@ -958,6 +975,14 @@
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))));
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) {
progress.dailyPlayLastDay = importedDailyLastDay;
progress.dailyPlayStreak = importedDailyStreak;
} else if (importedDailyLastDay === progress.dailyPlayLastDay) {
progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak);
}
}
const progressChanged = previousProgress !== JSON.stringify(progress);
changed = changed || progressChanged;
@ -990,11 +1015,12 @@
}
let changed = evaluateCleanFreak(world, detail);
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);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (progress.placementCount >= 100) changed = unlock("placed_objects_100", { ...detail, world, count: progress.placementCount }) || changed;
}
return changed;
@ -1015,7 +1041,7 @@
if (!state.unlocked.unplanned_city_30) {
progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (progress.quickDeleteCount >= 30) changed = unlock("unplanned_city_30", { ...detail, world, count: progress.quickDeleteCount }) || changed;
}
return changed;
@ -1029,7 +1055,7 @@
const achievementId = action === "undo" ? "undo_20" : "redo_20";
progress[key] = Math.min(20, progress[key] + 1);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
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;
@ -1054,7 +1080,7 @@
const progress = ensureProgress();
progress.linkTypes = [...new Set([...(progress.linkTypes || []), linkType])];
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
return evaluateMechanizedIndustry({ ...detail, type: linkType });
}
@ -1064,7 +1090,7 @@
if (!progress.signalActivated) {
progress.signalActivated = true;
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
}
return evaluateMechanizedIndustry(detail);
}
@ -1076,24 +1102,67 @@
return false;
}
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");
if (!hasNest) return false;
const aliveTarinai = (worldRef.tarinai || []).some(tarinai => tarinai && !tarinai.dead);
if (aliveTarinai) return false;
return unlock("ant_nest_without_tarinai", { ...detail, world: worldRef });
}
function recordStickyBombPass(item = null, detail = {}) {
if (!item || item.dead || item.type !== "sticky_bomb" || state.unlocked.sticky_bomb_15_passes) return false;
item.stickyBombPassCount = Math.max(0, Math.floor(Number(item.stickyBombPassCount || 0) || 0)) + 1;
if (item.stickyBombPassCount < 15) return false;
return unlock("sticky_bomb_15_passes", { ...detail, item, count: item.stickyBombPassCount });
}
function recordCircuitBoardSignalTransfer(sourceBoard = null, targetBoard = null, wire = null, detail = {}) {
if (state.unlocked.information_industry) return false;
if (!sourceBoard || !targetBoard || sourceBoard === targetBoard) return false;
if (sourceBoard.type !== "circuit_board" || targetBoard.type !== "circuit_board") return false;
if (!wire || !["wire", "insulated_wire"].includes(wire.type) || !wire.signalActive) return false;
return unlock("information_industry", { ...detail, sourceBoard, targetBoard, wire });
}
function recordWirePowerState(wire = null, active = false) {
if (!wire || (typeof wire !== "object" && typeof wire !== "function")) return false;
if (!active) return false;
if (!wireShockTrackers.has(wire)) wireShockTrackers.set(wire, { targets: new WeakSet(), count: 0 });
return true;
}
function recordWireShock(wire = null, target = null, detail = {}) {
if (state.unlocked.wire_shock_7_tarinai || !wire || !target || wire.type !== "wire" || !wire.signalActive) return false;
const world = detail.world || wire.world || target.world || global.world || null;
if (!world || !(world.tarinai || []).includes(target) || target.dead) return false;
recordWirePowerState(wire, true);
const tracker = wireShockTrackers.get(wire);
if (!tracker || tracker.targets.has(target)) return false;
tracker.targets.add(target);
tracker.count += 1;
if (tracker.count < WIRE_SHOCK_TARGET) return false;
return unlock("wire_shock_7_tarinai", { ...detail, world, wire, target, count: tracker.count });
}
function recordAntKilled(detail = {}) {
let changed = false;
const progress = ensureProgress();
if (!state.unlocked.ants_killed_100) {
progress.antKills = Math.min(100, progress.antKills + 1);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (progress.antKills >= 100) changed = unlock("ants_killed_100", detail) || changed;
}
const world = detailWorld(detail);
const ant = detail.ant || null;
const now = Math.max(0, Number(world?.time || 0) || 0);
const aliveCount = (world?.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
const lastDamageAt = Number(world?.achievementLastTarinaiDamageAt);
const undamagedForTenSeconds = !Number.isFinite(lastDamageAt) || now - lastDamageAt >= 10;
if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30 && undamagedForTenSeconds) {
if (world && aliveCount < 30) world.achievementEnemyAntKills = 0;
if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30) {
world.achievementEnemyAntKills = Math.min(10, Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) + 1);
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (world.achievementEnemyAntKills >= 10) changed = unlock("enemy_enemy_friend", { ...detail, world, count: world.achievementEnemyAntKills }) || changed;
}
return changed;
@ -1118,7 +1187,10 @@
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;
if (world && dangerSource) {
world.achievementLastTarinaiDamageAt = now;
world.achievementEnemyAntKills = 0;
}
tarinai._achievementLastDamageSource = dangerSource ? {
danger: true,
at: now,
@ -1204,36 +1276,100 @@
return false;
}
function recordPauseClick(detail = {}) {
function recordEmptyClick(detail = {}) {
if (state.unlocked.pause_spam_4_in_1_second) return false;
const now = Number(detail.now);
const timestamp = Number.isFinite(now) ? now : Date.now();
pauseClickTimes = pauseClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
pauseClickTimes.push(timestamp);
if (pauseClickTimes.length >= 4) {
pauseClickTimes = [];
emptyClickTimes = emptyClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
emptyClickTimes.push(timestamp);
if (emptyClickTimes.length >= 4) {
emptyClickTimes = [];
return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 });
}
return false;
}
function recordGroundChange(detail = {}) {
const world = detailWorld(detail);
const previous = String(detail.previous || "");
const next = String(detail.next || world?.groundType || "");
if (!world || detail.silent || !previous || !next || previous === next) return false;
let changed = false;
if (!state.unlocked.park_ground_changed && String(world.fieldType || "") === "park") {
changed = unlock("park_ground_changed", { ...detail, world, previous, next }) || changed;
}
if (!state.unlocked.ground_change_4_in_1_second) {
const now = Number(detail.now);
const timestamp = Number.isFinite(now) ? now : Date.now();
groundChangeTimes = groundChangeTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
groundChangeTimes.push(timestamp);
if (groundChangeTimes.length >= 4) {
groundChangeTimes = [];
changed = unlock("ground_change_4_in_1_second", { ...detail, world, count: 4, windowMs: 1000 }) || changed;
}
}
return changed;
}
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);
if (count < 5) return false;
return unlock("sandbox_five_toilets", { ...detail, world, count });
}
function recordLowFps(detail = {}) {
if (state.unlocked.low_fps_single_digit) return false;
const fps = Number.isFinite(Number(detail.fps)) ? Number(detail.fps) : Number(global.__tarinaiFps);
if (!Number.isFinite(fps) || fps <= 0 || fps >= 10) return false;
return unlock("low_fps_single_digit", { ...detail, fps });
}
function localDayNumber(value = Date.now()) {
const date = new Date(Number(value));
if (!Number.isFinite(date.getTime())) return 0;
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS);
}
function recordDailyPlay(detail = {}) {
if (state.unlocked.daily_play_7_days) return false;
const today = localDayNumber(Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now());
if (!today) return false;
const progress = ensureProgress();
const previousDay = progress.dailyPlayLastDay;
if (today < previousDay || today === previousDay) return false;
progress.dailyPlayStreak = today === previousDay + 1
? Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak + 1)
: 1;
progress.dailyPlayLastDay = today;
saveState();
renderIfDialogOpen();
if (progress.dailyPlayStreak >= DAILY_PLAY_TARGET) {
return unlock("daily_play_7_days", { ...detail, day: today, streak: progress.dailyPlayStreak });
}
return false;
}
function recordContinuousPlay(detail = {}) {
if (state.unlocked.continuous_play_1_hour) return false;
if (state.unlocked.continuous_play_1_hour && state.unlocked.continuous_play_24_hours) return false;
const explicitElapsed = Number(detail.elapsedMs);
let elapsedMs;
if (Number.isFinite(explicitElapsed)) {
elapsedMs = Math.max(0, explicitElapsed);
} else {
const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now();
if (document.hidden) return false;
if (!continuousPlayStartedAt || !continuousPlayLastHeartbeatAt || timestamp - continuousPlayLastHeartbeatAt > 15000 || timestamp < continuousPlayLastHeartbeatAt) {
continuousPlayStartedAt = timestamp;
}
if (!continuousPlayStartedAt || timestamp < continuousPlayStartedAt) continuousPlayStartedAt = timestamp;
continuousPlayLastHeartbeatAt = timestamp;
elapsedMs = Math.max(0, timestamp - continuousPlayStartedAt);
}
if (elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) return unlock("continuous_play_1_hour", { ...detail, elapsedMs });
return false;
let changed = false;
if (!state.unlocked.continuous_play_1_hour && elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) {
changed = unlock("continuous_play_1_hour", { ...detail, elapsedMs }) || changed;
}
if (!state.unlocked.continuous_play_24_hours && elapsedMs >= CONTINUOUS_PLAY_24H_TARGET_MS) {
changed = unlock("continuous_play_24_hours", { ...detail, elapsedMs }) || changed;
}
return changed;
}
function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) {
@ -1253,7 +1389,7 @@
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);
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (world.achievementRobotCleanCount >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count: world.achievementRobotCleanCount });
return false;
}
@ -1304,7 +1440,7 @@
const progress = ensureProgress();
progress.firstAidHeals = Math.min(50, progress.firstAidHeals + 1);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (progress.firstAidHeals >= 50) return unlock("town_doctor_50", { ...detail, count: progress.firstAidHeals, recovered });
return false;
}
@ -1330,7 +1466,7 @@
if (!progress.medicineTypes.includes(type)) {
progress.medicineTypes = [...progress.medicineTypes, type];
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
}
if (MEDICINE_LEDGER_TYPES.every(required => progress.medicineTypes.includes(required))) {
changed = unlock("medicine_ledger_all", { ...detail, count: progress.medicineTypes.length }) || changed;
@ -1374,7 +1510,7 @@
changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightMochiFightCount, fightMochiInfluenced: true }) || changed;
} else {
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
}
}
const now = Math.max(0, Number(world.time || 0) || 0);
@ -1398,7 +1534,7 @@
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 });
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
return false;
}
@ -1410,7 +1546,7 @@
return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount });
}
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
return false;
}
@ -1486,7 +1622,7 @@
if (!state.unlocked.great_mother_1000_births) {
progress.birthCount = Math.min(3333, progress.birthCount + 1);
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (progress.birthCount >= 3333) unlock("great_mother_1000_births", { ...detail, count: progress.birthCount });
}
if (state.unlocked.birth_50_in_60_seconds) return;
@ -1500,11 +1636,15 @@
function recordDeath(detail = {}) {
recordFightPairDangerDeath(detail);
const reason = String(detail.reason || detail.tarinai?.deathReason || "");
const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason);
const world = detail.world || detail.tarinai?.world || null;
if (world) {
evaluateMegalopolis(world, detail);
const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
if (!lifespanDeath) {
const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
}
}
const now = Number(world?.time || 0) || 0;
const tracker = runtimeTracker(world, now);
@ -1513,7 +1653,6 @@
tracker.deathTimes = tracker.deathTimes.filter(time => time >= now - 10.0001);
if (tracker.deathTimes.length >= 50) unlock("death_50_in_10_seconds", detail);
}
const reason = String(detail.reason || detail.tarinai?.deathReason || "");
const tarinai = detail.tarinai || null;
if (reason.includes("\u5929\u5bff\u3092\u5168\u3046\u3057\u305f")) {
unlock("lifespan_completed", detail);
@ -1555,7 +1694,7 @@
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);
if (!dom.dialog?.classList.contains("hidden")) render();
renderIfDialogOpen();
if (world.achievementDirectFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: world.achievementDirectFeedCount });
return false;
}
@ -1577,7 +1716,8 @@
|| !state.unlocked.rain_shelter_all
|| !state.unlocked.sauna_cold_plunge
|| !state.unlocked.idle_observer_5_minutes
|| !state.unlocked.safe_colony_25_5_minutes;
|| !state.unlocked.safe_colony_25_5_minutes
|| !state.unlocked.ant_nest_without_tarinai;
const needsAntScan = !state.unlocked.ants_alive_25;
const needsTemperatureItemScan = !state.unlocked.below_absolute_zero_item;
const needsCleanFreakScan = !state.unlocked.clean_freak_robot_only;
@ -1666,7 +1806,9 @@
}
}
if (!state.unlocked.sandbox_five_toilets) evaluateSandbox(worldRef, { source: "world-evaluation" });
if (!state.unlocked.ants_alive_25) recordAntPopulation(worldRef);
if (!state.unlocked.ant_nest_without_tarinai) evaluateAntNestWithoutTarinai(worldRef, { source: "world-evaluation" });
if (!state.unlocked.below_absolute_zero_item) recordBelowAbsoluteZeroItem(worldRef);
if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" });
if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" });
@ -1686,16 +1828,19 @@
state.resetPending = true;
runtimeByWorld = new WeakMap();
dangerFightDeathsByWorld = new WeakMap();
wireShockTrackers = new WeakMap();
toastQueue = [];
toastActive = false;
pauseClickTimes = [];
continuousPlayStartedAt = document.hidden ? 0 : Date.now();
emptyClickTimes = [];
groundChangeTimes = [];
continuousPlayStartedAt = Date.now();
continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
if (global.world) {
resetWorldProgress(global.world);
global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0);
}
saveState();
recordDailyPlay({ source: "reset" });
render();
clearTimeout(showUnlockToast.absorbTimer);
clearTimeout(showUnlockToast.hideTimer);
@ -1736,14 +1881,14 @@
dom.button?.focus?.();
}
dom.pauseButton?.addEventListener("click", () => recordPauseClick());
document.addEventListener("visibilitychange", () => {
continuousPlayStartedAt = document.hidden ? 0 : Date.now();
continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now();
});
if (typeof global.setInterval === "function") {
global.setInterval(() => {
if (!document.hidden) recordContinuousPlay();
recordDailyPlay();
recordLowFps();
recordHeldTarinai(global.world, Date.now());
}, 1000);
if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS);
@ -1801,10 +1946,19 @@
recordLinkPlaced,
recordSignalActivation,
recordAntPopulation,
evaluateAntNestWithoutTarinai,
recordAntKilled,
recordStickyBombPass,
recordCircuitBoardSignalTransfer,
recordWirePowerState,
recordWireShock,
recordSaveSlotsFilled,
recordPauseClick,
recordEmptyClick,
recordGroundChange,
evaluateSandbox,
recordLowFps,
recordContinuousPlay,
recordDailyPlay,
recordBelowAbsoluteZeroItem,
recordDamageSource,
recordAntDamageSource,
@ -1816,6 +1970,7 @@
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);

1117
js/circuit_board_system.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,12 @@
const solid = worldRef?.solidObstacleRects?.(item) || [];
if (solid.length) return solid;
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
if (item?.type === "circuit_board") {
const circuit = global.TarinaiCircuitBoardSystem;
const halfW = Number(circuit?.boardHalfWidth?.(item) || 32) || 32;
const halfH = Number(circuit?.boardHalfHeight?.(item) || 32) || 32;
return [{ oriented: true, cx: num(item.x), cy: num(item.y), halfW, halfH, angle: num(item.angle), type: item.type, item }];
}
return solid;
}

View file

@ -35,10 +35,14 @@
const nearestAvg = nearestAverage(alive);
const fieldId = String(worldRef?.fieldType || "garden");
const overcrowdLimit = fieldId === "cage" ? 28 : (fieldId === "park" ? 140 : 75);
const previousPopulation = Number(worldRef?.lastColonyMoodPopulation);
const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation;
return {
now,
alive,
n,
previousPopulation,
populationIncreasing,
avgEnergy: avgOf(alive, t => t.energy || 0),
avgStress: avgOf(alive, t => t.stress || 0),
avgHunger: avgOf(alive, t => t.hunger || 0),
@ -70,7 +74,7 @@
add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25);
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.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34);
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("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18);
add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd);
@ -87,7 +91,9 @@
const id = choose(worldRef, metrics);
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
worldRef.colonyMood = next;
worldRef.nextColonyMoodEvalAt = now + 1.25;
worldRef.lastColonyMoodPopulation = metrics.n;
const dayLength = Math.max(1, Number(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120);
worldRef.nextColonyMoodEvalAt = now + dayLength / 12;
global.TarinaiAchievements?.evaluateWorld?.(worldRef, next);
return next;
}

View file

@ -5,7 +5,6 @@
// and visual effects. Previously this bootstrap lived inline in several
// modules; keeping it here prevents helper drift and load-order surprises.
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
@ -28,10 +27,48 @@
};
const deterministicFrame = (worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6);
const deterministicUnit = (worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"));
const finiteOr = (value, fallback = 0) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
};
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, finiteOr(value, min)));
const isPlainObject = value => Boolean(value && typeof value === "object" && !Array.isArray(value));
const clonePlain = (value, fallback = {}) => {
if (value == null) return fallback;
try {
if (typeof structuredClone === "function") return structuredClone(value);
} catch (_) {}
return JSON.parse(JSON.stringify(value));
};
const birthHash32 = (seed = "") => {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
if (Number.isFinite(parsed)) return parsed >>> 0;
}
let h = 2166136261;
const value = String(seed || "");
for (let i = 0; i < value.length; i++) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
};
const isEffectCauseText = (text = "") => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
global.TarinaiCoreHelpers = Object.freeze({
...(global.TarinaiCoreHelpers || {}),
finiteOr,
clampNumber,
isPlainObject,
clonePlain,
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

View file

@ -4,14 +4,11 @@
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
// editor hit-testing, and world obstacle queries.
(function (global) {
function num(v, fallback = 0) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function clampValue(v, min, max) {
return Math.max(min, Math.min(max, v));
}
});
const clampValue = global.TarinaiCoreHelpers?.clampNumber || ((v, min, max) => Math.max(min, Math.min(max, v)));
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
const c = Math.cos(num(angle));

View file

@ -84,6 +84,7 @@
if (old && old !== carrier) {
old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18);
old.thought = "\u7c98\u7740\u30dc\u30e0\u304c\u79fb\u3063\u305f";
if (reason === "transfer") global.TarinaiAchievements?.recordStickyBombPass?.(item, { world: worldRef, from: old, to: carrier });
}
if (worldRef?.effects && typeof Effect !== "undefined") {
worldRef.effects.push(new Effect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" }));

View file

@ -76,6 +76,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
function duplicatorApplyStoredPin(duplicator, worldRef) {
const storedType = String(duplicator?.storedFoodType || "");
if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false;
if (globalThis.TarinaiSignalSystem?.powerOverride?.(duplicator) === false) return false;
const now = Number(worldRef?.time || 0);
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
const dish = duplicatorLoadPoint(duplicator);

View file

@ -549,6 +549,7 @@ draw(ctx, t, lighting = null) {
} else if (this.type === "duplicator") {
ctx.save();
const loaded = !!this.storedFoodType;
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9);
bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6");
bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7");
@ -566,7 +567,7 @@ draw(ctx, t, lighting = null) {
ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)";
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)";
ctx.fillStyle = !powered ? "rgba(180,76,70,0.72)" : (loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)");
ctx.beginPath();
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
ctx.fill();
@ -624,6 +625,76 @@ draw(ctx, t, lighting = null) {
ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (this.type === "seesaw") {
const r = this.r || 60;
const tilt = Number(this.seesawTilt || 0) || 0;
ctx.save();
ctx.fillStyle = styleNight ? "rgba(81,68,57,0.96)" : "rgba(130,99,69,0.98)";
ctx.strokeStyle = styleNight ? "rgba(41,34,29,0.86)" : "rgba(78,55,37,0.82)";
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(-r * 0.22, r * 0.42);
ctx.lineTo(0, -r * 0.05);
ctx.lineTo(r * 0.22, r * 0.42);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.save();
ctx.rotate(tilt);
const grad = ctx.createLinearGradient(0, -r * 0.14, 0, r * 0.14);
grad.addColorStop(0, styleNight ? "#c5895c" : "#f0ad6f");
grad.addColorStop(1, styleNight ? "#8d5d3f" : "#ba7447");
ctx.fillStyle = grad;
ctx.strokeStyle = styleNight ? "rgba(57,37,29,0.9)" : "rgba(104,59,35,0.85)";
roundedRect(ctx, -r * 1.08, -r * 0.13, r * 2.16, r * 0.26, r * 0.08);
ctx.fill(); ctx.stroke();
for (const side of [-1, 1]) {
ctx.fillStyle = side < 0 ? "rgba(88,177,231,0.98)" : "rgba(239,111,116,0.98)";
roundedRect(ctx, side * r * 0.79 - r * 0.18, -r * 0.26, r * 0.36, r * 0.22, r * 0.07);
ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(70,55,45,0.75)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(side * r * 0.90, -r * 0.22);
ctx.lineTo(side * r * 0.90, -r * 0.52);
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = "rgba(70,58,48,0.9)";
ctx.beginPath(); ctx.arc(0, 0, r * 0.13, 0, Math.PI * 2); ctx.fill();
ctx.restore();
} else if (this.type === "circuit_board") {
const circuit = globalThis.TarinaiCircuitBoardSystem;
const angle = Number(this.angle || 0) || 0;
const active = Object.values(this._circuitOutputState || {}).some(Boolean);
ctx.save();
ctx.rotate(angle);
ctx.fillStyle = active ? "rgba(44,139,82,0.98)" : (styleNight ? "rgba(41,91,62,0.98)" : "rgba(47,126,76,0.98)");
ctx.strokeStyle = styleNight ? "rgba(19,48,31,0.92)" : "rgba(26,74,42,0.90)";
ctx.lineWidth = 2.4;
const boardHalfW = Number(circuit?.boardHalfWidth?.(this) ?? circuit?.HALF_W ?? 32) || 32;
const boardHalfH = Number(circuit?.boardHalfHeight?.(this) ?? circuit?.HALF_H ?? 32) || 32;
roundedRect(ctx, -boardHalfW, -boardHalfH, boardHalfW * 2, boardHalfH * 2, 10); ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(216,190,81,0.64)";
ctx.lineWidth = 1.4;
const traceRows = Math.max(1, Math.min(7, Math.floor(boardHalfH / 9)));
for (let i = -traceRows; i <= traceRows; i++) {
const y = i * Math.max(5, boardHalfH / Math.max(2, traceRows * 1.7));
ctx.beginPath(); ctx.moveTo(-boardHalfW * 0.72, y); ctx.lineTo(-boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke();
ctx.beginPath(); ctx.moveTo(boardHalfW * 0.72, y); ctx.lineTo(boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke();
}
ctx.fillStyle = "rgba(30,39,34,0.94)";
roundedRect(ctx, -15, -16, 30, 32, 5); ctx.fill();
ctx.fillStyle = "rgba(236,245,235,0.92)";
ctx.font = "bold 11px ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText("LOGIC", 0, 0);
for (const port of circuit?.ports?.(this) || []) {
const local = circuit.localPortPosition(port, this);
const on = port.direction === "input" ? Boolean(this._circuitInputState?.[port.id]) : Boolean(this._circuitOutputState?.[port.id]);
ctx.fillStyle = on ? "rgba(255,228,91,1)" : (port.direction === "input" ? "rgba(92,189,255,0.96)" : "rgba(255,143,77,0.96)");
ctx.strokeStyle = "rgba(25,35,31,0.88)";
ctx.lineWidth = 1.4;
ctx.beginPath(); ctx.arc(local.x, local.y, 5.4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.restore();
} else if (this.type === "signboard") {
ctx.save();
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
@ -685,8 +756,9 @@ draw(ctx, t, lighting = null) {
} else if (this.type === "dry_ice") {
const r = this.r || 21;
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
ctx.save();
if (itemShadowsEnabled()) {
if (powered && itemShadowsEnabled()) {
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
ctx.shadowBlur = 11;
}
@ -737,7 +809,7 @@ draw(ctx, t, lighting = null) {
roundedRect(ctx, -bodyW * 0.48, y + bodyH - footH * 0.4, bodyW * 0.96, footH, r * 0.10);
ctx.fill();
for (let i = 0; i < 4; i++) {
if (powered) for (let i = 0; i < 4; i++) {
const phase = (time * (0.42 + i * 0.06) + i * 0.23 + (this.seed || 0)) % 1;
const sx = -r * 0.50 + i * r * 0.33 + Math.sin(time * 1.4 + i) * r * 0.06;
const sy = y - capH * 0.62 - phase * r * 0.76;
@ -750,8 +822,9 @@ draw(ctx, t, lighting = null) {
ctx.restore();
} else if (this.type === "stove") {
const r = this.r || 24;
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
ctx.save();
if (itemShadowsEnabled()) {
if (powered && itemShadowsEnabled()) {
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
ctx.shadowBlur = 9;
}
@ -764,17 +837,19 @@ draw(ctx, t, lighting = null) {
ctx.fillStyle = "rgba(42, 32, 28, 0.92)";
roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14);
ctx.fill();
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
ctx.beginPath();
ctx.moveTo(0, r * 0.16);
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
ctx.fill();
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
ctx.beginPath();
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
ctx.fill();
if (powered) {
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
ctx.beginPath();
ctx.moveTo(0, r * 0.16);
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
ctx.fill();
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
ctx.beginPath();
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
@ -873,9 +948,11 @@ draw(ctx, t, lighting = null) {
const r = this.r || 26;
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
const windAngle = itemAngleFor(this);
const spin = (this.fanSpin || 0) + (window.TarinaiSignalSystem?.powerOverride?.(this) === false ? 0 : t * 10.5);
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
const spin = (this.fanSpin || 0) + (powered ? t * 10.5 : 0);
ctx.save();
ctx.rotate(windAngle);
if (powered) {
const windRange = 320;
const windStart = r * 1.02;
const windSpread = 0.62;
@ -934,6 +1011,7 @@ draw(ctx, t, lighting = null) {
ctx.stroke();
}
ctx.globalAlpha = fanAlpha;
}
ctx.restore();
ctx.save();
ctx.rotate(bodyAngle);
@ -1235,7 +1313,7 @@ draw(ctx, t, lighting = null) {
ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke();
ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill();
const detectorMark = { tarinai:"\u305f", item:"\u7269", time:"\u6642", hunger_avg:"\u8179", stress_avg:"\u5727", sleep_avg:"\u7720", low_hp_count:"HP", sick_count:"\u75c5", temperature:"\u2103", zunchi_count:"\u7cde", ant_count:"\u87fb", water_count:"\u6c34" }[this.pressureTarget] || "\u305f";
const detectorLabel = this.pressureTarget === "time" ? detectorMark : `${detectorMark}${Number(this.pressureThreshold ?? 1)}`;
const detectorLabel = this.pressureTarget === "time" ? detectorMark : `${detectorMark}${Number(this.pressureMin ?? 1)}-${Number(this.pressureMax ?? 300)}`;
ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(detectorLabel,0,14);
ctx.restore();
} else if (this.type === "rotator") {

View file

@ -44,14 +44,14 @@ const TOOL_DEFINITIONS = Object.freeze({
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u79fb\u52d5\u3067\u304d\u308b\u5730\u4e0b\u7a7a\u9593\u3002\u305f\u308a\u306a\u3044\u304c\u7720\u308b\u3053\u3068\u3082\u3042\u308b\u3002" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0b\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0a\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
balloon: { id: "balloon", itemType: "balloon", label: "\u6c34\u98a8\u8239", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "\ud83d\udca7", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u306e\u304a\u3082\u3061\u3083\u3002\u5272\u308c\u308b\u3068\u6c34\u6ef4\u304c\u98db\u3073\u6563\u308b\u3002" },
fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "\ud83c\udf00", collisionShape: "circle", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3059\u3002\u8efd\u3044\u3082\u306e\u3092\u62bc\u3057\u3001\u5c11\u3057\u6dbc\u3057\u304f\u3059\u308b\u3002Q/E\u3067\u7f6e\u304f\u5411\u304d\u3092\u5909\u3048\u308b\u3002\u914d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u958b\u304f\u3002" },
fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "\ud83c\udf00", collisionShape: "circle", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3059\u3002\u8efd\u3044\u3082\u306e\u3092\u62bc\u3057\u3001\u5c11\u3057\u6dbc\u3057\u304f\u3059\u308b\u3002Q/E\u3067\u7f6e\u304f\u5411\u304d\u3092\u5909\u3048\u308b\u3002\u914d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u958b\u304f\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f9b\u7d66\u3059\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true },
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" },
@ -69,6 +69,8 @@ const TOOL_DEFINITIONS = Object.freeze({
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "\u691c\u77e5\u5668", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u25a3", collisionShape: "circle", tooltip: "\u69d8\u3005\u306a\u72b6\u6cc1\u3092\u691c\u77e5\u3057\u3066\u96fb\u7dda\u306b\u4fe1\u53f7\u3092\u9001\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
seesaw: { id: "seesaw", itemType: "seesaw", label: "\u30b7\u30fc\u30bd\u30fc", placeable: true, scalable: false, radius: 60, amount: 999, iconText: "\u2195", collisionShape: "circle", tooltip: "\u4e21\u7aef\u306b\u305f\u308a\u306a\u3044\u304c\u4e57\u308b\u3068\u4e8c\u5339\u3067\u904a\u3073\u3001\u4e92\u3044\u306b\u53cb\u597d\u3092\u7bc9\u304f\u3002" },
circuit_board: { id: "circuit_board", itemType: "circuit_board", label: "\u57fa\u76e4", placeable: true, scalable: false, radius: 46, amount: 999, iconText: "\u25a6", collisionShape: "oriented_rect", tooltip: "\u96fb\u7dda\u306e\u4fe1\u53f7\u3067\u8ad6\u7406\u6f14\u7b97\u304c\u3067\u304d\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" },
trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },

View file

@ -3,21 +3,23 @@
// Static item/tool metadata shared by tool definitions and UI helpers.
(function (global) {
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "rain_shelter", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]),
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "rain_shelter", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "circuit_board"]),
food_interest: new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
hazard: new Set(["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
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"]),
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"]),
});
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 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"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]);
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"]));
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
@ -28,8 +30,9 @@
{ 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: "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", "signboard"] },
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
{ 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"] },
]);
@ -54,6 +57,8 @@
FENCE_ITEM_TYPES,
MECHANICAL_ITEM_TYPES,
PHYSICS_TOOL_IDS,
PHYSICS_CATEGORY_TOOL_IDS,
ELECTRIC_TOOL_IDS,
ROTATABLE_ITEM_TYPES,
PIN_BEHAVIORS,
TOOL_CATEGORIES,

View file

@ -256,10 +256,29 @@ function initializeItemTypeState(item, type, x, y) {
};
}
if (type === "seesaw") {
item.amount = 999;
item.angle = 0;
item.seesawSeatIds = ["", ""];
item.seesawPhase = 0;
item.seesawTilt = 0;
item.seesawPlayTime = 0;
item.deletable = true;
}
if (type === "circuit_board") {
item.amount = 999;
item.angle = defaultItemAngle(type);
item.circuitConfig = globalThis.TarinaiCircuitBoardSystem?.defaultConfig?.() || { v: 4, cols: 10, rows: 10, inputs: [], outputs: [], nodes: [], wires: [] };
globalThis.TarinaiCircuitBoardSystem?.ensureConfig?.(item);
item._circuitInputState = Object.create(null);
item._circuitOutputState = Object.create(null);
item.deletable = true;
}
if (type === "pressure_switch") {
item.pressureTarget = "tarinai";
item.pressureComparator = "gte";
item.pressureThreshold = 1;
item.pressureMin = 1;
item.pressureMax = 300;
item.pressureWidth = 220;
item.pressureHeight = 160;
item.pressureTimeStart = 360;

View file

@ -4,10 +4,10 @@
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
(function (global) {
const footprints = global.TarinaiCollisionFootprints;
function num(v, fallback = 0) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
});
function rectCorners(rect, padding = 0) {
return footprints?.rectCorners?.(rect, padding) || [];

View file

@ -4,7 +4,7 @@
// Shared tiny helpers for positional projection used by links, mechanical
// bodies, and circle/circle collision code.
(function (global) {
function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; });
function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) {
const world = worldRef || item?.world || null;

View file

@ -7,17 +7,15 @@
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
function num(value, fallback = 0) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
});
function bool(value, fallback = true) {
if (value == null) return !!fallback;
return !!value;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, num(value, min)));
}
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
function isBodyType(typeOrItem) {
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
return BODY_TYPES.has(type);
@ -271,6 +269,7 @@
x: num(endpoint.x),
y: num(endpoint.y),
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
portId: String(endpoint.portId || ""),
fuzzyResolve: endpoint.fuzzyResolve === true,
supportLost: endpoint.supportLost === true,
_ref: endpoint._ref || null,
@ -476,7 +475,7 @@
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null };
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null, port: String(ep.portId || "") };
};
return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } };
}
@ -513,7 +512,7 @@
const kind = ep.kind || "item";
const refIdx = Number(ep.refIdx);
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null;
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), portId: String(ep.port || ""), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
};
const midArr = Array.isArray(p.mid) ? p.mid : [];
item.physicsConstraint = normalizeConstraint({ type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : (item.type === "rod" ? "rigid-distance" : (item.type === "spring" ? "spring-distance" : "signal-wire"))), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);

View file

@ -5,10 +5,10 @@
// of placement blocking. Render and actual placement both delegate here so a
// green preview means the same footprint the click will try to place.
(function (global) {
function num(v, fallback = 0) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
});
function itemRadius(type, fallback = 12) {
return global.itemRadiusFor(type, fallback);
}
@ -181,8 +181,10 @@
}
if (item.type === "rain_shelter") {
const footprint = global.TarinaiParasolSystem?.parasolFootprint?.(item) || null;
if (footprint) return { shape: "ellipse", centerX: footprint.centerX, centerY: footprint.centerY, radiusX: footprint.radiusX, radiusY: footprint.radiusY };
if (footprint) return { shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY, halfWidth: footprint.radiusX, halfHeight: footprint.radiusY, cornerRadius: Math.max(8, Math.min(18, footprint.radiusY * 0.32)) };
}
const def = global.toolDefinition?.(item.type) || null;
if (def?.collisionShape === "circle") return { shape: "circle", centerX: num(item.x), centerY: num(item.y), radius: Math.max(8, num(item.r, itemRadius(item.type, 12))) };
return null;
}
function geometryFor(worldRef, item) {

View file

@ -175,6 +175,44 @@ function strokeWorldObstacleRect(ctx, rect) {
return true;
}
function drawWireConnectableHighlights(ctx, world) {
if (!world || !["wire", "insulated_wire"].includes(String(world.tool || ""))) return;
const signal = globalThis.TarinaiSignalSystem;
const circuit = globalThis.TarinaiCircuitBoardSystem;
if (!signal?.isSignalConnectable) return;
const pulse = 0.72 + Math.sin((world.time || 0) * 7.5) * 0.13;
const pointer = world.pointer || {};
ctx.save();
ctx.setLineDash([7, 5]);
const blockedTypes = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
for (const item of world.items || []) {
if (!item || item.dead || item.type === "splat" || blockedTypes.has(item.type)) continue;
const signalCapable = signal.isSignalConnectable(item);
if (item.type === "circuit_board") {
for (const port of circuit?.ports?.(item) || []) {
const point = circuit.portWorld(item, port);
if (!point) continue;
const hovered = pointer.inside && Math.hypot(pointer.x - point.x, pointer.y - point.y) <= 17;
const selected = world.pendingLinkEndpoint?.id === item.id && world.pendingLinkEndpoint?.portId === port.portId;
ctx.globalAlpha = selected ? 1 : (hovered ? 0.98 : 0.72);
ctx.fillStyle = port.direction === "input" ? "rgba(74,177,255,0.22)" : "rgba(255,132,65,0.22)";
ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (port.direction === "input" ? "rgba(74,177,255,0.98)" : "rgba(255,132,65,0.98)");
ctx.lineWidth = selected || hovered ? 3.8 : 2.5;
ctx.beginPath(); ctx.arc(point.x, point.y, (selected || hovered ? 12 : 9) * pulse, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
continue;
}
const selected = world.pendingLinkEndpoint?.id === item.id;
const radius = Math.max(18, Number(item.r || 22) * 1.22 + 7);
ctx.globalAlpha = selected ? 1 : (signalCapable ? 0.66 : 0.30);
ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (signalCapable ? "rgba(90,207,255,0.94)" : "rgba(224,232,238,0.72)");
ctx.lineWidth = selected ? 4 : (signalCapable ? 2.4 : 1.6);
ctx.beginPath(); ctx.ellipse(item.x, item.y, radius, radius * 0.78, 0, 0, Math.PI * 2); ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
}
function drawToolTargetHoverHighlight(ctx, world) {
if (!world) return;
const tool = String(world.tool || "");
@ -495,7 +533,6 @@ function drawPlacementPreview(ctx, world) {
const preview = placementPreviewFor(world);
if (!preview) return;
const { type, x, y, r, rect, rects, overlay, influence, blocked } = preview;
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
ctx.save();
ctx.globalAlpha = blocked ? 0.44 : 0.72;
ctx.lineWidth = blocked ? 2.2 : 2.0;
@ -519,9 +556,9 @@ function drawPlacementPreview(ctx, world) {
ctx.stroke();
}
};
if (overlay?.shape === "ellipse") {
if (overlay?.shape === "circle") {
ctx.beginPath();
ctx.ellipse(overlay.centerX, overlay.centerY, Math.max(1, overlay.radiusX), Math.max(1, overlay.radiusY), 0, 0, Math.PI * 2);
ctx.arc(overlay.centerX, overlay.centerY, Math.max(1, overlay.radius), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (overlay?.shape === "roundedRect") {
@ -542,14 +579,9 @@ function drawPlacementPreview(ctx, world) {
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
}
}
} else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
ctx.beginPath();
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.beginPath();
ctx.ellipse(x, y, Math.max(9, r * 1.55), Math.max(6, r * 0.86), 0, 0, Math.PI * 2);
ctx.arc(x, y, Math.max(8, r), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
@ -585,8 +617,8 @@ function drawPlacementPreview(ctx, world) {
ctx.fill();
ctx.restore();
}
const markerX = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
const markerY = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
const markerX = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
const markerY = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
ctx.globalAlpha = blocked ? 0.52 : 0.70;
ctx.beginPath();
ctx.moveTo(markerX - 8, markerY);
@ -711,7 +743,7 @@ function drawOperationToolPreview(ctx, world) {
}
} else {
const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16);
ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.arc(item.x, item.y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.setLineDash([]);
const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x;
@ -901,7 +933,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"));
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw"));
}
function renderLayerSortY(entity) {
@ -1764,6 +1796,7 @@ function render() {
}
renderPerfEnd(endAttachments);
drawWireConnectableHighlights(ctx, world);
drawToolTargetHoverHighlight(ctx, world);
const endEffects = renderPerfBegin("render.draw.effects");

View file

@ -635,7 +635,7 @@
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
}
function birthHash32(seed = "") {
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
@ -648,7 +648,7 @@
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
});
function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1));
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
@ -1095,6 +1095,7 @@
if (!indexed && (Number(endpoint.x) || 0) !== (Number(row?.[1]) || 0)) mask |= 128;
if (!indexed && (Number(endpoint.y) || 0) !== (Number(row?.[2]) || 0)) mask |= 256;
if (endpoint.t != null && Number.isFinite(Number(endpoint.t))) mask |= 512;
if (endpoint.port) mask |= 1024;
writer.u(mask);
if (mask & 1) writer.str(endpoint.id);
if (mask & 2) writer.str(endpoint.type);
@ -1105,6 +1106,7 @@
if (mask & 128) writer.s((Number(endpoint.x) || 0) - (Number(row?.[1]) || 0));
if (mask & 256) writer.s((Number(endpoint.y) || 0) - (Number(row?.[2]) || 0));
if (mask & 512) writer.u(endpoint.t);
if (mask & 1024) writer.str(endpoint.port);
}
function readPhysicsEndpoint(reader, row = []) {
const [present] = readBitFields(reader, [1]);
@ -1124,7 +1126,8 @@
const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0);
const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0);
const t = (mask & 512) ? reader.u() : null;
return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t };
const port = (mask & 1024) ? reader.str() : "";
return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t, port };
}
function writePhysicsConstraintExtra(writer, extra, type, row = []) {
const constraint = extra?.constraint && typeof extra.constraint === "object" ? extra.constraint : {};
@ -1215,11 +1218,12 @@
else if (type === "zunchi") writeFixedSignedDefaults(writer, list, [0, 100, 100], 3);
else if (type === "signboard") writeSaveString(writer, list[0] || "");
else if (type === "robot_cleaner") writeFixedSignedDefaults(writer, list, [3, 0], 2);
else if (type === "circuit_board") writeSaveString(writer, list[0] || "{}");
else if (type === "duplicator") writeFixedSignedDefaults(writer, list, [-1], 1);
else if (type === "ball" || type === "balloon") writeFixedSignedDefaults(writer, list, [0, 0], 2);
else if (type === "ant_nest") writeFixedSignedDefaults(writer, list, [0, 0], 2);
else if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") writeFixedSignedDefaults(writer, list, [0], 1);
else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1], 2);
else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1, 0], 3);
else if (type === "nest_box") writeFixedSignedDefaults(writer, list, [0, 0, 0, 0], 4);
else if (isPinSaveType(type)) writeFixedSignedDefaults(writer, list, [0, -1, 0, 0, 0], 5);
else if (type === "fan") {
@ -1242,11 +1246,12 @@
if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3);
if (type === "signboard") return [readSaveString(reader)];
if (type === "robot_cleaner") return readFixedSignedDefaults(reader, [3, 0], 2);
if (type === "circuit_board") return [readSaveString(reader)];
if (type === "duplicator") return readFixedSignedDefaults(reader, [-1], 1);
if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2);
if (type === "ant_nest") return readFixedSignedDefaults(reader, [0, 0], 2);
if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") return readFixedSignedDefaults(reader, [0], 1);
if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1], 2);
if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1, 0], 3);
if (type === "nest_box") return readFixedSignedDefaults(reader, [0, 0, 0, 0], 4);
if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5);
if (type === "fan") {
@ -1459,16 +1464,11 @@
return items;
}
const LEGACY_JSON_BINARY_SCHEMA_VERSION = 41;
const LEGACY_COMPACT_BINARY_SCHEMA_VERSION = 42;
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
const ACHIEVEMENT_SPELL_BINARY_VERSION = 3;
const PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION = 2;
const ACHIEVEMENT_MASK_BYTES = 7;
const LEGACY_ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10]);
const LEGACY_ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666]);
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721]);
const ACHIEVEMENT_SPELL_BINARY_VERSION = 5;
const ACHIEVEMENT_MASK_BYTES = 8;
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071]);
function finiteNumberOrNull(value) {
const n = Number(value);
@ -1493,34 +1493,14 @@
function achievementMaskBytesFromSpell(spell = null) {
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
const version = Number(spell[0]);
let baseMinute = 0;
let deltas = [];
let progress = [];
if (version === 1) {
const maskHex = String(spell[1] || "").toLowerCase();
if (!/^[0-9a-f]{14}$/.test(maskHex)) throw new Error("invalid achievement spell mask");
for (let i = 0; i < ACHIEVEMENT_MASK_BYTES; i++) bytes[i] = parseInt(maskHex.slice(i * 2, i * 2 + 2), 16);
const baseSecond = toSafeUInt(spell[2]);
const secondDeltas = Array.isArray(spell[3]) ? spell[3] : [];
const absoluteMinutes = secondDeltas.map(delta => Math.floor((baseSecond + toSafeUInt(delta)) / 60));
baseMinute = absoluteMinutes.length ? Math.min(...absoluteMinutes) : 0;
deltas = absoluteMinutes.map(value => value - baseMinute);
progress = Array.isArray(spell[4]) ? spell[4] : [];
} else if (version === PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION || version === ACHIEVEMENT_SPELL_BINARY_VERSION) {
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
const high = Math.max(0, Math.min(0xffffff, Math.floor(Number(spell[2]) || 0)));
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
for (let i = 0; i < 3; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
baseMinute = toSafeUInt(spell[3]);
deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : [];
const rawProgress = Array.isArray(spell[5]) ? spell[5] : [];
progress = version === ACHIEVEMENT_SPELL_BINARY_VERSION
? rawProgress
: [...rawProgress.slice(0, 9), 0, 0, 0];
} else {
throw new Error("unsupported achievement spell version");
}
if (Number(spell[0]) !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version");
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0)));
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
const baseMinute = toSafeUInt(spell[3]);
const deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : [];
const progress = Array.isArray(spell[5]) ? spell[5] : [];
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
@ -1529,11 +1509,12 @@
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
return { bytes, baseMinute, deltas, progress };
}
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
let low = 0;
let high = 0;
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
for (let i = 0; i < 3; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
return [ACHIEVEMENT_SPELL_BINARY_VERSION, low >>> 0, high >>> 0, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
}
function writeAchievementProgress(writer, values = [], bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
@ -1568,7 +1549,7 @@
for (const delta of normalized.deltas) writer.u(delta);
writeAchievementProgress(writer, normalized.progress);
}
function readAchievementSpell(reader, schema = BINARY_SCHEMA_VERSION) {
function readAchievementSpell(reader) {
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
let unlockedCount = 0;
for (const byte of bytes) {
@ -1578,11 +1559,8 @@
const baseMinute = reader.u();
const deltas = [];
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
const legacy = schema === LEGACY_COMPACT_BINARY_SCHEMA_VERSION;
const progress = legacy
? readAchievementProgress(reader, LEGACY_ACHIEVEMENT_PROGRESS_BITS, LEGACY_ACHIEVEMENT_PROGRESS_MAX)
: readAchievementProgress(reader);
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, legacy ? [...progress.slice(0, 9), 0, 0, 0] : progress);
const progress = readAchievementProgress(reader);
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
}
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
const value = metadata && typeof metadata === "object" ? metadata : {};
@ -1749,7 +1727,7 @@
const formatHeader = reader.b();
const schema = formatHeader & 0x7f;
const hasStringTable = Boolean(formatHeader & 0x80);
if (![BINARY_SCHEMA_VERSION, LEGACY_COMPACT_BINARY_SCHEMA_VERSION, LEGACY_JSON_BINARY_SCHEMA_VERSION].includes(schema)) throw new Error("unsupported binary save schema");
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
readStringTable(reader, hasStringTable);
const w = readWorld(reader);
const tCount = reader.u();
@ -1760,12 +1738,7 @@
const items = readItemBlocks(reader);
let g = null;
try {
if (schema === LEGACY_JSON_BINARY_SCHEMA_VERSION) {
const encodedMetadata = reader.str();
g = encodedMetadata ? JSON.parse(encodedMetadata) : null;
} else {
g = readAchievementMetadata(reader, tCount, items.length, schema);
}
g = readAchievementMetadata(reader, tCount, items.length, schema);
} catch (_) {
throw new Error("invalid achievement metadata in binary save");
}

View file

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

217
js/seesaw_system.js Normal file
View file

@ -0,0 +1,217 @@
"use strict";
// Two-creature leisure equipment. Seats are reserved independently and only
// produce play/friendship while both ends are occupied.
(function (global) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => Number.isFinite(Number(v)) ? Number(v) : fallback);
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((v, a, b) => Math.max(a, Math.min(b, Number(v) || 0)));
function ensure(item) {
if (!item) return null;
if (!Array.isArray(item.seesawSeatIds)) item.seesawSeatIds = ["", ""];
if (item.seesawSeatIds.length < 2) item.seesawSeatIds = [item.seesawSeatIds[0] || "", item.seesawSeatIds[1] || ""];
item.angle = 0;
item.seesawPhase = num(item.seesawPhase);
item.seesawTilt = num(item.seesawTilt);
return item;
}
function seatWorld(item, index = 0) {
ensure(item);
const side = index === 1 ? 1 : -1;
const reach = Math.max(48, num(item?.r, 60) * 0.94);
return { x: num(item?.x) + reach * side, y: num(item?.y) - 2, index: index === 1 ? 1 : 0 };
}
function actorById(worldRef, id) {
if (!id) return null;
return worldRef?.liveTarinaiById?.(id) || (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null;
}
function release(tarinai, item = null) {
const board = item || tarinai?.target;
if (board?.type === "seesaw") {
ensure(board);
for (let i = 0; i < 2; i += 1) if (board.seesawSeatIds[i] === tarinai?.id) board.seesawSeatIds[i] = "";
}
if (tarinai) {
tarinai.seesawItemId = "";
tarinai.seesawSeatIndex = -1;
tarinai.seesawWaitStartedAt = -Infinity;
tarinai.seesawPlayStartedAt = -Infinity;
}
return true;
}
function cleanReservations(item, worldRef) {
ensure(item);
for (let i = 0; i < 2; i += 1) {
const actor = actorById(worldRef, item.seesawSeatIds[i]);
if (!actor || actor.dead || actor.seesawItemId !== item.id || actor.seesawSeatIndex !== i || actor.state !== "play_seesaw") item.seesawSeatIds[i] = "";
}
}
function findAvailable(worldRef, tarinai, range = 560) {
let best = null;
let bestScore = Number(range) || 560;
const list = worldRef?.itemsOfType?.("seesaw") || (worldRef?.items || []).filter(item => item?.type === "seesaw");
for (const item of list) {
if (!item || item.dead) continue;
cleanReservations(item, worldRef);
for (let index = 0; index < 2; index += 1) {
const reserved = item.seesawSeatIds[index];
if (reserved && reserved !== tarinai?.id) continue;
const point = seatWorld(item, index);
const d = Math.hypot(num(tarinai?.x) - point.x, num(tarinai?.y) - point.y);
const hasPartner = Boolean(item.seesawSeatIds[1 - index]);
const score = d - (hasPartner ? 90 : 0);
if (score < bestScore) { bestScore = score; best = { item, seatIndex: index, point, hasPartner }; }
}
}
return best;
}
function shouldSeek(worldRef, tarinai, needs = null) {
const found = findAvailable(worldRef, tarinai, 620);
if (!found) return false;
const fulfill = num(needs?.fulfill ?? tarinai?.needs?.fulfill);
const social = num(needs?.social ?? tarinai?.needs?.social);
if (found.hasPartner) return Math.max(fulfill, social) >= 8;
return fulfill >= 26 || social >= 40;
}
function actionPriority(worldRef, tarinai) {
const found = findAvailable(worldRef, tarinai, 620);
if (!found) return 0;
return found.hasPartner ? 74 : 34;
}
function isPlaying(tarinai, worldRef = tarinai?.world) {
const reservation = validReservation(tarinai, worldRef);
if (!reservation) return false;
const partnerId = reservation.item.seesawSeatIds[1 - reservation.index];
const partner = actorById(worldRef, partnerId);
if (!partner || partner.dead || partner.state !== "play_seesaw") return false;
const ownPoint = reservation.point;
const partnerPoint = seatWorld(reservation.item, 1 - reservation.index);
const ownArrived = Math.hypot(num(tarinai.x) - ownPoint.x, num(tarinai.y) - ownPoint.y) <= Math.max(28, num(tarinai.radius, 20) * 1.25);
const partnerArrived = Math.hypot(num(partner.x) - partnerPoint.x, num(partner.y) - partnerPoint.y) <= Math.max(28, num(partner.radius, 20) * 1.25);
return ownArrived && partnerArrived;
}
function reserve(item, tarinai, preferredIndex = null) {
if (!item || item.dead || item.type !== "seesaw" || !tarinai || tarinai.dead) return -1;
ensure(item);
const candidates = preferredIndex === 0 || preferredIndex === 1 ? [preferredIndex, 1 - preferredIndex] : [0, 1];
for (const index of candidates) {
if (!item.seesawSeatIds[index] || item.seesawSeatIds[index] === tarinai.id) {
item.seesawSeatIds[index] = tarinai.id;
tarinai.seesawItemId = item.id || "";
tarinai.seesawSeatIndex = index;
tarinai.seesawWaitStartedAt = num(tarinai.world?.time, 0);
tarinai.seesawPlayStartedAt = -Infinity;
return index;
}
}
return -1;
}
function start(tarinai, worldRef, item = null) {
const found = item ? { item, seatIndex: null } : findAvailable(worldRef, tarinai, 620);
const target = found?.item;
if (!target) return false;
cleanReservations(target, worldRef);
const index = reserve(target, tarinai, found.seatIndex);
if (index < 0) return false;
tarinai.setActionState?.("play_seesaw", { target, reason: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b", sleeping: false, wake: true });
tarinai.target = target;
tarinai.thought = "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b";
return true;
}
function validReservation(tarinai, worldRef) {
if (!tarinai || tarinai.dead || tarinai.state !== "play_seesaw") return null;
const item = worldRef?.itemById?.(tarinai.seesawItemId) || (worldRef?.items || []).find(value => value && !value.dead && value.id === tarinai.seesawItemId);
if (!item || item.type !== "seesaw") return null;
cleanReservations(item, worldRef);
const index = tarinai.seesawSeatIndex === 1 ? 1 : 0;
if (item.seesawSeatIds[index] !== tarinai.id) return null;
return { item, index, point: seatWorld(item, index) };
}
function tickAction(tarinai, worldRef) {
const reservation = validReservation(tarinai, worldRef);
if (!reservation) return false;
const d = Math.hypot(num(tarinai.x) - reservation.point.x, num(tarinai.y) - reservation.point.y);
tarinai.thought = d > Math.max(22, num(tarinai.radius, 20)) ? "\u30b7\u30fc\u30bd\u30fc\u3078\u5411\u304b\u3063\u3066\u3044\u308b" : (reservation.item.seesawSeatIds[1 - reservation.index] ? "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3067\u3044\u308b" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b");
const now = num(worldRef?.time);
if (Number.isFinite(tarinai.seesawPlayStartedAt) && tarinai.seesawPlayStartedAt > -Infinity) {
if (now - tarinai.seesawPlayStartedAt >= 18) return "finished";
} else {
const dayLength = Math.max(1, num(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength, 120));
if (now - num(tarinai.seesawWaitStartedAt, now) >= Math.max(30, dayLength * 0.5)) return "finished";
}
return true;
}
function updateWorld(worldRef, dt = 0.016) {
if (!worldRef?.items) return false;
const step = Math.max(0, Math.min(0.1, num(dt, 0.016)));
const seesaws = worldRef.itemsOfType?.("seesaw") || worldRef.items.filter(item => item?.type === "seesaw");
for (const item of seesaws) {
if (!item || item.dead) continue;
ensure(item); cleanReservations(item, worldRef);
const actors = [actorById(worldRef, item.seesawSeatIds[0]), actorById(worldRef, item.seesawSeatIds[1])];
const arrived = actors.map((actor, index) => {
if (!actor) return false;
const point = seatWorld(item, index);
return Math.hypot(num(actor.x) - point.x, num(actor.y) - point.y) <= Math.max(28, num(actor.radius, 20) * 1.25);
});
const playing = Boolean(actors[0] && actors[1] && arrived[0] && arrived[1]);
const pairKey = playing ? `${actors[0].id}|${actors[1].id}` : "";
if (pairKey && item.seesawPairKey !== pairKey) {
item.seesawPairKey = pairKey;
const now = num(worldRef.time);
actors[0].seesawPlayStartedAt = now;
actors[1].seesawPlayStartedAt = now;
} else if (!pairKey) item.seesawPairKey = "";
if (playing) item.seesawPhase += step * 4.6;
const targetTilt = playing ? Math.sin(item.seesawPhase) * 0.16 : 0;
item.seesawTilt += (targetTilt - item.seesawTilt) * Math.min(1, step * 8);
for (let index = 0; index < 2; index += 1) {
const actor = actors[index];
if (!actor || !arrived[index]) continue;
const base = seatWorld(item, index);
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.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;
if (playing && typeof global.setBehaviorText === "function") {
const behavior = global.currentTarinaiBehavior?.(actor);
if (behavior?.actionId === "play_seesaw" && behavior?.reason !== actionText) {
global.setBehaviorText(actor, { need: "fulfill", subNeed: "social_play", actionId: "play_seesaw", actionLabel: actionText, reasonText: actionText, target: item, phase: "perform", source: behavior?.source || "need" });
}
}
}
if (playing) {
const [a, b] = actors;
a.adjustRelation?.(b, step * 0.70, -step * 0.025, "seesaw_play");
b.adjustRelation?.(a, step * 0.70, -step * 0.025, "seesaw_play");
if (typeof global.applyNeedSatisfaction === "function") {
global.applyNeedSatisfaction(a, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw");
global.applyNeedSatisfaction(b, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw");
} else {
a.stress = clamp(num(a.stress) - step * 0.35, 0, 100);
b.stress = clamp(num(b.stress) - step * 0.35, 0, 100);
}
item.seesawPlayTime = num(item.seesawPlayTime) + step;
}
}
return true;
}
global.TarinaiSeesawSystem = Object.freeze({ ensure, seatWorld, findAvailable, shouldSeek, actionPriority, isPlaying, reserve, release, start, tickAction, updateWorld, validReservation });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -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"]);
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]);
const DETECTOR_METRICS = Object.freeze({
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
@ -34,11 +34,11 @@
});
function num(value, fallback = 0) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clamp(value, min, max) { return Math.max(min, Math.min(max, num(value, min))); }
});
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
function normalizeMinute(value, fallback = 0) {
return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5));
}
@ -91,6 +91,18 @@
return found;
}
function endpointSignalNode(endpoint, object, worldRef) {
if (!endpoint || !object) return object || null;
if (object.type !== "circuit_board") return object;
const circuit = global.TarinaiCircuitBoardSystem;
const portId = endpoint.portId || circuit?.nearestPort?.(object, endpoint.x, endpoint.y, 80)?.port?.portId || "";
return circuit?.terminal?.(object, portId) || null;
}
function isSignalConnectable(item) {
return Boolean(item && !item.dead && (SIGNAL_SOURCE_TYPES.has(item.type) || DIRECT_SIGNAL_SINK_TYPES.has(item.type) || item.type === "circuit_board"));
}
function endpointPoint(endpoint, worldRef) {
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
@ -145,9 +157,10 @@
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
if (!item || item.dead) continue;
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
item.pressureComparator = normalizeComparator(item.pressureComparator);
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
item.pressureThreshold = clamp(num(item.pressureThreshold, defaultThreshold), -999, 999);
item.pressureMin = clamp(num(item.pressureMin, defaultThreshold), -999, 999);
item.pressureMax = clamp(num(item.pressureMax, 999), -999, 999);
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
@ -159,7 +172,7 @@
value = active ? 1 : 0;
} else {
value = detectorMetricValue(item, worldRef);
active = item.pressureComparator === "lte" ? value <= item.pressureThreshold : value >= item.pressureThreshold;
active = value >= item.pressureMin && value <= item.pressureMax;
}
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
item.pressureCount = value;
@ -192,30 +205,40 @@
function buildSignalNetwork(worldRef) {
const adjacency = new Map();
const connected = new Set();
const connectedNodes = new Set();
const wires = [];
const circuit = global.TarinaiCircuitBoardSystem;
let wireGeometryMoved = false;
const addEdge = (a, b) => {
if (!a || !b || a === b) return;
if (!adjacency.has(a)) adjacency.set(a, new Set());
if (!adjacency.has(b)) adjacency.set(b, new Set());
adjacency.get(a).add(b); adjacency.get(b).add(a);
connected.add(a); connected.add(b);
connectedNodes.add(a); connectedNodes.add(b);
};
const boards = [];
for (const item of worldRef.items || []) {
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);
}
}
for (const wire of worldRef.items || []) {
if (!isWire(wire)) continue;
const pb = global.TarinaiPhysicsBodySystem;
const endpointA = pb?.endpoint?.(wire, 0);
const endpointB = pb?.endpoint?.(wire, 1);
const a = endpointObject(endpointA, worldRef);
const b = endpointObject(endpointB, worldRef);
const objectA = endpointObject(endpointA, worldRef);
const objectB = endpointObject(endpointB, worldRef);
const nodeA = endpointSignalNode(endpointA, objectA, worldRef);
const nodeB = endpointSignalNode(endpointB, objectB, worldRef);
const pointA = endpointPoint(endpointA, worldRef);
const pointB = endpointPoint(endpointB, worldRef);
if (pointA && pointB) {
@ -227,39 +250,81 @@
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
}
wires.push({ wire, a, b });
if (a && b) addEdge(a, b);
wires.push({ wire, a: objectA, b: objectB, nodeA, nodeB });
if (nodeA && nodeB) addEdge(nodeA, nodeB);
}
const components = [];
const componentOf = new Map();
const visited = new Set();
for (const start of connected) {
if (visited.has(start)) continue;
const stack = [start];
const component = [];
let active = false;
for (const startNode of connectedNodes) {
if (visited.has(startNode)) continue;
const stack = [startNode], nodes = [];
while (stack.length) {
const node = stack.pop();
if (!node || visited.has(node)) continue;
visited.add(node); component.push(node);
if (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) active = true;
visited.add(node); nodes.push(node);
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
}
const hasSource = component.some(node => SIGNAL_SOURCE_TYPES.has(node.type));
const componentSet = new Set(component);
for (const node of component) {
const component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) };
components.push(component);
nodes.forEach(node => componentOf.set(node, component));
}
// Board outputs can depend on other boards, so resolve the graph to a
// fixed point. Unstable internal combinational cycles are forced OFF by the
// board evaluator; external feedback is still bounded to avoid hanging the simulation.
const maxCircuitPasses = Math.min(256, Math.max(16, boards.length * 2 + 4));
for (let pass = 0; pass < maxCircuitPasses; pass += 1) {
let changed = false;
for (const component of components) {
const active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
if (active !== component.active) { component.active = active; changed = true; }
}
for (const board of boards) {
const inputs = Object.create(null);
for (const port of circuit?.ports?.(board) || []) {
if (port.direction !== "input") continue;
const terminal = circuit.terminal(board, port.portId);
inputs[port.id] = Boolean(componentOf.get(terminal)?.active);
}
const before = JSON.stringify(board._circuitOutputState || {});
circuit?.evaluate?.(board, inputs);
if (before !== JSON.stringify(board._circuitOutputState || {})) changed = true;
}
if (!changed) break;
}
for (const component of components) {
component.active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
for (const node of component.nodes) {
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
node._wireSignalDriven = hasSource;
node._wireSignalOn = hasSource && active;
setEffectivePower(node, hasSource, active, worldRef);
node._wireSignalDriven = component.hasSource;
node._wireSignalOn = component.hasSource && component.active;
setEffectivePower(node, component.hasSource, component.active, worldRef);
}
for (const entry of wires) {
if ((entry.a && componentSet.has(entry.a)) || (entry.b && componentSet.has(entry.b))) entry.wire.signalActive = hasSource && active;
}
for (const entry of wires) {
const component = componentOf.get(entry.nodeA) || componentOf.get(entry.nodeB);
entry.wire.signalActive = Boolean(component?.hasSource && component?.active);
if (!entry.wire.signalActive || entry.a === entry.b) continue;
let sourceNode = null, targetNode = null, sourceBoard = null, targetBoard = null;
if (entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "output"
&& entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "input") {
sourceNode = entry.nodeA; targetNode = entry.nodeB; sourceBoard = entry.a; targetBoard = entry.b;
} else if (entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "output"
&& entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "input") {
sourceNode = entry.nodeB; targetNode = entry.nodeA; sourceBoard = entry.b; targetBoard = entry.a;
}
if (!sourceNode || !targetNode || sourceBoard === targetBoard) continue;
const sourcePortId = String(sourceNode.portId || "").replace(/^o:/, "");
const targetPortId = String(targetNode.portId || "").replace(/^i:/, "");
if (!sourceBoard?._circuitOutputState?.[sourcePortId] || !targetBoard?._circuitInputState?.[targetPortId]) continue;
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) && !connected.has(item)) {
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) {
item._wireSignalDriven = false; item._wireSignalOn = false;
setEffectivePower(item, false, false, worldRef);
}
@ -272,12 +337,7 @@
return wires;
}
function distanceToSegment(px, py, ax, ay, bx, by) {
const vx = bx - ax, vy = by - ay;
const len2 = vx * vx + vy * vy || 1;
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
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;
@ -306,12 +366,14 @@
life: 0.28,
color: "rgba(118,224,255,0.96)",
});
global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected });
return true;
}
function damageFromWires(worldRef, wires) {
const now = num(worldRef.time);
for (const { wire, a, b } of wires) {
global.TarinaiAchievements?.recordWirePowerState?.(wire, Boolean(wire.signalActive));
if (!wire.signalActive) continue;
// Living endpoints are directly energized even through insulated wire.
if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true });
@ -358,6 +420,9 @@
isWire,
isMinuteInRange,
currentMinuteOfDay,
isSignalConnectable,
DIRECT_SIGNAL_SINK_TYPES,
SIGNAL_SOURCE_TYPES,
SENSOR_METRICS: DETECTOR_METRICS,
DETECTOR_METRICS,
PRESSURE_TARGETS,

View file

@ -33,7 +33,7 @@
return Number.isFinite(n) ? n / scale : fallback;
}
function birthHash32(seed = "") {
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
@ -46,7 +46,7 @@
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
});
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
@ -76,7 +76,7 @@
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function clonePlain(value, depth = 0) {
function cloneSnapshotPlain(value, depth = 0) {
if (value == null) return value;
const type = typeof value;
if (type === "number") return Number.isFinite(value) ? value : 0;
@ -86,7 +86,7 @@
if (Array.isArray(value)) {
const out = [];
for (const v of value) {
const c = clonePlain(v, depth + 1);
const c = cloneSnapshotPlain(v, depth + 1);
if (c !== undefined) out.push(c);
}
return out;
@ -95,7 +95,7 @@
const out = {};
for (const [key, v] of Object.entries(value)) {
if (DEFAULT_SKIP.has(key)) continue;
const c = clonePlain(v, depth + 1);
const c = cloneSnapshotPlain(v, depth + 1);
if (c !== undefined) out[key] = c;
}
return out;
@ -417,6 +417,7 @@
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 === "circuit_board") return [JSON.stringify(global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || item.circuitConfig || {})];
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex);
if (packed) return packed;
@ -427,7 +428,7 @@
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)];
if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1];
if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1, q(item.stickyBombPassCount, 1)];
if (type === "fire") return [q(item.flameLife, 10)];
if (type === "nest_box") return [q(item.burnTimer, 10), q(item.nestFireEvacuationTimer, 10), item.burning ? 1 : 0, item.nestFireEvacuated ? 1 : 0];
if (global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
@ -444,8 +445,8 @@
const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"];
return [
Math.max(0, targets.indexOf(item.pressureTarget || "tarinai")),
item.pressureComparator === "lte" ? 1 : 0,
q(item.pressureThreshold ?? 1, 10),
q(item.pressureMin ?? 1, 10),
q(item.pressureMax ?? 300, 10),
Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0)),
Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0)),
Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0)),
@ -483,8 +484,8 @@
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;
item.roles = clonePlain(def.roles) || item.roles || {};
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
item.roles = cloneSnapshotPlain(def.roles) || item.roles || {};
item.needEffects = cloneSnapshotPlain(def.needEffects) || item.needEffects || {};
}
return;
}
@ -516,7 +517,7 @@
} else if (type === "firecracker" || type === "flame_firecracker") {
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
} else if (type === "sticky_bomb") {
item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || "";
item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || ""; item.stickyBombPassCount = Math.max(0, u(extra[2], 1, 0));
} else if (type === "fire") {
item.flameLife = u(extra[0], 10, item.flameLife || 6);
} else if (type === "nest_box") {
@ -530,6 +531,11 @@
}
} else if (global.isPinType(type)) {
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
} else if (type === "circuit_board") {
let config = {};
try { config = JSON.parse(String(extra[0] || "{}")); } catch (_) { config = {}; }
global.TarinaiCircuitBoardSystem?.applySerializedConfig?.(item, config);
global.TarinaiCircuitBoardSystem?.evaluate?.(item);
} else if (type === "fan") {
const fallback = global.defaultItemAngle(type);
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
@ -542,8 +548,9 @@
} else if (type === "pressure_switch") {
const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"];
item.pressureTarget = targets[Number(extra[0]) | 0] || "tarinai";
item.pressureComparator = Number(extra[1]) === 1 ? "lte" : "gte";
item.pressureThreshold = u(extra[2], 10, 1);
item.pressureMin = u(extra[1], 10, 1);
item.pressureMax = u(extra[2], 10, 300);
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
item.pressureWidth = Math.max(60, Math.min(840, Number(extra[3] || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(extra[4] || 160) | 0));
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(extra[5] ?? 360) | 0));

View file

@ -5,9 +5,7 @@
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value || {}));
}
const clonePlain = global.TarinaiCoreHelpers?.clonePlain || (value => JSON.parse(JSON.stringify(value || {})));
class TarinaiStructure {
constructor(definition, owner, x, y, world) {

View file

@ -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: "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); } },
{ id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } },

View file

@ -535,6 +535,22 @@ const TARINAI_ACTION_DEFINITIONS = [
createApproachMateActionSpec(),
createFightRivalActionSpec(),
createIntimidateEnemyActionSpec(),
{
id: "play_seesaw",
need: "fulfill",
subNeed: "social_play",
state: "play_seesaw",
label: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b",
phrase: "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3060",
weight: 31,
timerBacked: true,
selectTarget(t, world) { return globalThis.TarinaiSeesawSystem?.findAvailable?.(world, t, 620)?.item || null; },
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) { return Boolean(globalThis.TarinaiSeesawSystem?.start?.(t, world, ctx.target ?? this.selectTarget(t, world, ctx))); },
tick(t, world) { return globalThis.TarinaiSeesawSystem?.tickAction?.(t, world) ?? false; },
finish(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return true; },
fail(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return false; },
},
{
id: "play",
need: "fulfill",

View file

@ -2,15 +2,11 @@
(function (global) {
const registry = new Map();
function isObject(value) {
return Boolean(value && typeof value === "object");
}
function finiteOr(value, fallback) {
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object"));
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
});
function hasOwnFn(obj, key) {

View file

@ -3,15 +3,11 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
function isObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function finiteOr(value, fallback) {
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object" && !Array.isArray(value)));
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
});
function nowFor(tarinai = null) {
return finiteOr(tarinai?.world?.time, 0);
@ -82,9 +78,7 @@
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
}
function isEffectCauseText(text = "") {
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
}
const isEffectCauseText = global.TarinaiCoreHelpers?.isEffectCauseText || (text => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")));
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
if (!isObject(input)) return null;

View file

@ -8,6 +8,7 @@ function fulfillPrimaryReason(tarinai, action = null) {
if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044";
if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b";
if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044";
if (actionId === "play_seesaw") return "\u904a\u3073\u305f\u3044";
if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b";
if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b";
@ -50,9 +51,8 @@ function actionTextForReason(action = null, tarinai = null) {
}
function isEffectCauseText(text = "") {
return typeof globalThis.isTarinaiEffectCauseText === "function"
? globalThis.isTarinaiEffectCauseText(text)
: /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text)
?? globalThis.isTarinaiEffectCauseText?.(text));
}
function forcedCausePhrase(source = "", fallback = "") {

View file

@ -5,6 +5,7 @@
(function (global) {
function updateCandidate(tarinai, ctx, it, dt = 0) {
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
if (it.type === "duplicator" && global.TarinaiSignalSystem?.powerOverride?.(it) === false) return false;
const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });

View file

@ -8,12 +8,12 @@
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
isServingFoodItem(it) {
if (it?.type === "duplicator" && it.storedFoodType) return true;
if (it?.type === "duplicator" && it.storedFoodType) return global.TarinaiSignalSystem?.powerOverride?.(it) !== false;
return Boolean(it && isServingFoodType(it.type));
},
foodItemHasServingLeft(it) {
if (!it || it.dead) return false;
if (it.type === "duplicator") return Boolean(it.storedFoodType);
if (it.type === "duplicator") return global.TarinaiSignalSystem?.powerOverride?.(it) !== false && Boolean(it.storedFoodType);
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
this.ensureFoodServings(it);
return (it.foodServingsRemaining || 0) > 0;
@ -34,7 +34,7 @@
},
consumeFoodServing(it, nutrition) {
if (it?.type === "duplicator") {
if (!it.storedFoodType) return 0;
if (!it.storedFoodType || global.TarinaiSignalSystem?.powerOverride?.(it) === false) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
it.usedCount = (it.usedCount || 0) + 1;
return value;

View file

@ -32,7 +32,7 @@ function effectiveFoodRoleMatches(item, role) {
function roleMatches(item, role) {
if (!item || item.dead) return false;
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false;
if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role);
if (item.type === "duplicator") return globalThis.TarinaiSignalSystem?.powerOverride?.(item) !== false && effectiveFoodRoleMatches(item, role);
if (item.roles?.[role]) return true;
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed";

View file

@ -84,6 +84,7 @@ const TARINAI_ACTION_PRIORITY = {
build_plushie: 28,
use_plushie: 24,
return_owned_structure: 22,
play_seesaw: 27,
play: 20,
wander_lightly: 10,
};

View file

@ -268,7 +268,7 @@ function actionSelectionBonus(action, need, tarinai, options = {}) {
const p = tarinai?.fulfillReasonParts || {};
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0);
else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2;
else if (action.id === "play" || action.id === "play_seesaw" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2;
else if (options.includeComfortActions && (action.id === "return_owned_structure" || action.id === "use_plushie")) {
bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5;
}
@ -311,6 +311,9 @@ function shouldStartActionBySubNeed(tarinai, action, needs) {
const status = tarinai?.temperatureStatus || tarinai?.world?.temperatureStatusFor?.(tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15), tarinai);
return Boolean(status && (status.discomfort || 0) >= 2.5);
}
if (action?.id === "play_seesaw") {
return Boolean(globalThis.TarinaiSeesawSystem?.shouldSeek?.(tarinai?.world, tarinai, needs));
}
if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs);
const parts = tarinai?.socialReasonParts || {};
const subNeed = action.subNeed;
@ -376,6 +379,9 @@ function choosePriorityNeedAction(needs, tarinai, world) {
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
priority = (status?.harmful || (status?.harmExcess || 0) > 0) ? 92 : 53;
}
if (action.id === "play_seesaw") {
priority = Math.max(priority, Number(globalThis.TarinaiSeesawSystem?.actionPriority?.(world, tarinai)) || 0);
}
if (action.id === "eat_food") {
const hunger = Number(tarinai?.hunger || 0) || 0;
if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120);
@ -495,6 +501,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) {
|| (action.id === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic")
|| (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04)
|| (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04)
|| (action.id === "play_seesaw" && Boolean(globalThis.TarinaiSeesawSystem?.validReservation?.(tarinai, world)))
|| ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15);
const foodSuppressed = need === "food" && !isCurrentBehaviorForced(tarinai) && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15);
if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) {

View file

@ -473,8 +473,8 @@
}
if (this.target && !this.target.dead) {
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target;
const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "ant_attack", "seek_temperature"]);
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : (this.state === "play_seesaw" ? (globalThis.TarinaiSeesawSystem?.seatWorld?.(this.target, this.seesawSeatIndex) || this.target) : this.target);
const routeStates = 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"]);
const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
@ -555,6 +555,9 @@
} else if (this.state === "play_ball") {
strength = d < this.radius + 18 ? 1.75 : 1.28;
sign = 1;
} else if (this.state === "play_seesaw") {
strength = d < this.radius + 12 ? 0.42 : 1.18;
sign = 1;
} else if (this.state === "fight") {
const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
if (d < 18) {

View file

@ -112,7 +112,7 @@
if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
lane = "birth";
interval = isVisible ? 0.12 : 0.24;
} else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball/.test(state)) {
} else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|play_seesaw|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball|play_seesaw/.test(state)) {
lane = "moving";
interval = isVisible ? 0.10 : (isNear ? 0.22 : 0.55);
} else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) {

View file

@ -1,7 +1,7 @@
"use strict";
function colonyMoodTip() {
const mood = world?.evaluateColonyMood?.(true) || world?.colonyMood || null;
const mood = world?.evaluateColonyMood?.() || world?.colonyMood || null;
return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002");
}

View file

@ -1,5 +1,7 @@
"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) ||

View file

@ -1,5 +1,7 @@
"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());

View file

@ -1,5 +1,7 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perfScale = window.TarinaiPerf.dprScale();

View file

@ -1,5 +1,7 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function loadLogPushSettings() {
try {
const raw = localStorage.getItem("tarinai_log_push_settings_v2");

View file

@ -1,6 +1,6 @@
"use strict";
const escapeHtml = window.TarinaiUIHelpers.htmlEscape;
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function quoteNameInRecordText(text, name) {
const raw = String(text || "");
@ -334,6 +334,7 @@ function isIdleDisplayText(value = "") {
function reasonLabel(t) {
if (!t) return "\u306a\u3057";
if (Number(t.electricShockUntil || 0) > Number(t.world?.time || world?.time || 0)) return "\u611f\u96fb\u3057\u3066\u3044\u308b\u3002";
if (t.state === "play_seesaw" && globalThis.TarinaiSeesawSystem?.isPlaying?.(t, t.world || world)) return "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002";
const behavior = behaviorText(t);
if (behavior) {
const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior;

View file

@ -320,7 +320,7 @@ function renderToolPalette() {
if (!def || def.simulationOnly || toolId === "observe") continue;
const btn = document.createElement("button");
btn.className = "tool";
const hasPhysicsIcon = category.id === "physics" && attachPhysicsToolIcon(btn, toolId);
const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId);
const hasTextIcon = !hasPhysicsIcon && applyToolTextIcon(btn, def, toolId);
if (!hasPhysicsIcon && !hasTextIcon) applyToolImageIcon(btn, def);
btn.dataset.tool = toolId;

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "39.16.60";
const APP_BUILD = "tarinai-ui-v39-16-60";
const APP_VERSION = "39.16.74";
const APP_BUILD = "tarinai-ui-v39-16-74";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -105,6 +105,8 @@ class World { constructor() {
this.liveIdSerial = 0;
this.liveTarinai = new Map();
this.lastBirthAt = -999;
this.lastColonyMoodPopulation = NaN;
this.nextColonyMoodEvalAt = 0;
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;

View file

@ -20,6 +20,15 @@
return dialog;
}
function bindEditorClose(dialog, close, selectors = []) {
if (!dialog || typeof close !== "function") return;
for (const selector of selectors) {
const button = dialog.querySelector(selector);
if (button) button.onclick = close;
}
dialog.onclick = (event) => { if (event.target === dialog) close(); };
}
function ensureSignboardEditor() {
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
@ -139,9 +148,7 @@
}
if (apply) apply.onclick = () => commit(area?.value || "");
if (clear) clear.onclick = () => commit("");
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
bindEditorClose(dialog, close, ["#signboardEditorCancel", "#signboardEditorClose"]);
dialog.classList.remove("hidden");
syncCount();
return true;
@ -211,11 +218,9 @@
};
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
if (closeBtn) closeBtn.onclick = close;
if (cancel) cancel.onclick = close;
bindEditorClose(dialog, close, ["#robotCleanerEditorClose", "#robotCleanerCancel"]);
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
if (apply) apply.onclick = () => commit(readMask());
dialog.onclick = (e) => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
@ -310,9 +315,7 @@
};
if (stop) stop.onclick = () => commit(true);
if (apply) apply.onclick = () => commit(false);
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
bindEditorClose(dialog, close, ["#fanEditorCancel", "#fanEditorClose"]);
dialog.classList.remove("hidden");
return true;
}
@ -445,9 +448,7 @@
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
bindEditorClose(dialog, close, ["#rotatorCancel", "#rotatorEditorClose"]);
controller.draw();
dialog.classList.remove("hidden");
return true;
@ -591,9 +592,7 @@
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
bindEditorClose(dialog, close, ["#reciprocatorCancel", "#reciprocatorEditorClose"]);
controller.draw();
dialog.classList.remove("hidden");
return true;
@ -659,13 +658,14 @@
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
};
if (item.type === "signboard") data.text = item.text || "";
if (item.type === "circuit_board") data.circuitConfig = global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || null;
if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
if (item.type === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
if (item.type === "pressure_switch") {
data.pressureTarget = item.pressureTarget || "tarinai";
data.pressureComparator = item.pressureComparator === "lte" ? "lte" : "gte";
data.pressureThreshold = Math.max(-999, Math.min(999, Number(item.pressureThreshold ?? 1) || 0));
data.pressureMin = Math.max(-999, Math.min(999, Number(item.pressureMin ?? 1) || 0));
data.pressureMax = Math.max(-999, Math.min(999, Number(item.pressureMax ?? 300) || 0));
data.pressureWidth = Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0));
data.pressureHeight = Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0));
data.pressureTimeStart = Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0));
@ -725,6 +725,7 @@
item.fanBodyAngle = Number(data.fanBodyAngle ?? item.fanBodyAngle ?? item.angle ?? 0) || 0;
}
if (item.type === "signboard") item.text = String(data.text || "");
if (item.type === "circuit_board") global.TarinaiCircuitBoardSystem?.applySerializedConfig?.(item, data.circuitConfig || {});
if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen);
if (item.type === "duplicator") {
item.storedFoodType = String(data.storedFoodType || "");
@ -734,8 +735,9 @@
if (item.type === "pressure_switch") {
const targets = new Set(["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"]);
item.pressureTarget = targets.has(data.pressureTarget) ? data.pressureTarget : "tarinai";
item.pressureComparator = data.pressureComparator === "lte" ? "lte" : "gte";
item.pressureThreshold = Math.max(-999, Math.min(999, Number(data.pressureThreshold ?? 1) || 0));
item.pressureMin = Math.max(-999, Math.min(999, Number(data.pressureMin ?? 1) || 0));
item.pressureMax = Math.max(-999, Math.min(999, Number(data.pressureMax ?? 300) || 0));
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
item.pressureWidth = Math.max(60, Math.min(840, Number(data.pressureWidth || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(data.pressureHeight || 160) | 0));
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(data.pressureTimeStart ?? 360) | 0));
@ -835,10 +837,17 @@
<div class="rotator-editor-titlebar"><h2 id="signalItemEditorTitle">\u691c\u77e5\u5668\u8a2d\u5b9a</h2><button id="signalItemEditorClose" class="rotator-editor-close" type="button">\u00d7</button></div>
<div class="rotator-editor-controls">
<label>\u691c\u77e5\u5bfe\u8c61 <select id="pressureSwitchTarget">${options}</select></label>
<label id="pressureSwitchComparatorRow">\u6bd4\u8f03 <select id="pressureSwitchComparator"><option value="gte">\u4ee5\u4e0a</option><option value="lte">\u4ee5\u4e0b</option></select></label>
<div id="pressureSwitchCountRow" class="pressure-numeric-control">
<div class="pressure-time-summary"><span>\u3057\u304d\u3044\u5024</span><output id="pressureSwitchCountLabel">1</output></div>
<input id="pressureSwitchCount" class="pressure-value-slider" type="range" min="0" max="300" step="1">
<div id="pressureSwitchCountRow" class="pressure-time-control">
<div class="pressure-time-summary"><span>\u4f5c\u52d5\u7bc4\u56f2</span><span><output id="pressureSwitchValueMinLabel">1</output><span aria-hidden="true"> \u301c </span><output id="pressureSwitchValueMaxLabel">300</output></span></div>
<div id="pressureSwitchValueRange" class="pressure-time-range">
<div id="pressureSwitchValueTrack" class="pressure-time-track" aria-hidden="true"></div>
<input id="pressureSwitchValueMin" class="pressure-time-thumb pressure-time-thumb-start" type="range" min="0" max="300" step="1" aria-label="\u4f5c\u52d5\u7bc4\u56f2\u306e\u4e0b\u9650">
<input id="pressureSwitchValueMax" class="pressure-time-thumb pressure-time-thumb-end" type="range" min="0" max="300" step="1" aria-label="\u4f5c\u52d5\u7bc4\u56f2\u306e\u4e0a\u9650">
</div>
<div class="pressure-number-inputs">
<label>\u4e0b\u9650 <input id="pressureSwitchValueMinNumber" type="number" inputmode="decimal"></label>
<label>\u4e0a\u9650 <input id="pressureSwitchValueMaxNumber" type="number" inputmode="decimal"></label>
</div>
</div>
<div id="pressureSwitchWidthRow" class="pressure-numeric-control pressure-spatial-control">
<div class="pressure-time-summary"><span>\u6a2a\u5e45</span><output id="pressureSwitchWidthLabel">220px</output></div>
@ -865,15 +874,19 @@
if (!item || item.type !== "pressure_switch") return false;
const dialog = ensureSignalItemEditor();
const target = dialog.querySelector("#pressureSwitchTarget");
const comparator = dialog.querySelector("#pressureSwitchComparator");
const count = dialog.querySelector("#pressureSwitchCount");
const countLabel = dialog.querySelector("#pressureSwitchCountLabel");
const valueRow = dialog.querySelector("#pressureSwitchCountRow");
const valueRange = dialog.querySelector("#pressureSwitchValueRange");
const valueTrack = dialog.querySelector("#pressureSwitchValueTrack");
const valueMin = dialog.querySelector("#pressureSwitchValueMin");
const valueMax = dialog.querySelector("#pressureSwitchValueMax");
const valueMinNumber = dialog.querySelector("#pressureSwitchValueMinNumber");
const valueMaxNumber = dialog.querySelector("#pressureSwitchValueMaxNumber");
const valueMinLabel = dialog.querySelector("#pressureSwitchValueMinLabel");
const valueMaxLabel = dialog.querySelector("#pressureSwitchValueMaxLabel");
const width = dialog.querySelector("#pressureSwitchWidth");
const widthLabel = dialog.querySelector("#pressureSwitchWidthLabel");
const height = dialog.querySelector("#pressureSwitchHeight");
const heightLabel = dialog.querySelector("#pressureSwitchHeightLabel");
const comparatorRow = dialog.querySelector("#pressureSwitchComparatorRow");
const countRow = dialog.querySelector("#pressureSwitchCountRow");
const widthRow = dialog.querySelector("#pressureSwitchWidthRow");
const heightRow = dialog.querySelector("#pressureSwitchHeightRow");
const timeRow = dialog.querySelector("#pressureSwitchTimeRow");
@ -885,13 +898,24 @@
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 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()) => {
const min = Number(def.min ?? 0), max = Number(def.max ?? 100), step = Math.max(0.0001, Number(def.step ?? 1));
const numeric = Number.isFinite(Number(value)) ? Number(value) : fallback;
const rounded = Math.round((numeric - min) / step) * step + min;
return Math.max(min, Math.min(max, Number(rounded.toFixed(6))));
};
const formatDetectorValue = (value, def) => `${Number(value) || 0}${def?.unit || ""}`;
target.value = DETECTOR_TARGETS[item.pressureTarget] ? item.pressureTarget : "tarinai";
comparator.value = item.pressureComparator === "lte" ? "lte" : "gte";
count.value = String(Number(item.pressureThreshold ?? DETECTOR_TARGETS[target.value].threshold));
const initialDef = targetDef();
valueMin.value = String(normalizeValue(item.pressureMin, initialDef.threshold, initialDef));
valueMax.value = String(normalizeValue(item.pressureMax, initialDef.max, initialDef));
width.value = String(item.pressureWidth || 220);
height.value = String(item.pressureHeight || 160);
timeStart.value = String(normalizeMinute(item.pressureTimeStart, 360));
timeEnd.value = String(normalizeMinute(item.pressureTimeEnd, 1080));
const updateTimeRange = () => {
const start = normalizeMinute(timeStart.value, 360), end = normalizeMinute(timeEnd.value, 1080);
timeStart.value = String(start); timeEnd.value = String(end);
@ -903,8 +927,25 @@
: `linear-gradient(to right, ${active} 0%, ${active} ${endPct}%, ${idle} ${endPct}%, ${idle} ${startPct}%, ${active} ${startPct}%, ${active} 100%)`;
if (timeRange) timeRange.dataset.wraps = start > end ? "1" : "0";
};
const updateValueRange = source => {
const def = targetDef();
let low = normalizeValue(source === "min-number" ? valueMinNumber.value : valueMin.value, def.threshold, def);
let high = normalizeValue(source === "max-number" ? valueMaxNumber.value : valueMax.value, def.max, def);
if (low > high) {
if (source === "min" || source === "min-number") high = low;
else low = high;
}
valueMin.value = String(low); valueMax.value = String(high);
valueMinNumber.value = String(low); valueMaxNumber.value = String(high);
valueMinLabel.textContent = formatDetectorValue(low, def); valueMaxLabel.textContent = formatDetectorValue(high, def);
const span = Math.max(0.0001, Number(def.max) - Number(def.min));
const lowPct = (low - Number(def.min)) / span * 100, highPct = (high - Number(def.min)) / span * 100;
const idle = "rgba(94,112,128,0.22)", active = "rgba(67,158,218,0.78)";
valueTrack.style.background = `linear-gradient(to right, ${idle} 0%, ${idle} ${lowPct}%, ${active} ${lowPct}%, ${active} ${highPct}%, ${idle} ${highPct}%, ${idle} 100%)`;
valueRange.dataset.wraps = "0";
};
const updateOverlay = () => {
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
const def = targetDef();
if (!def.spatial) {
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
} else {
@ -912,13 +953,7 @@
}
global.render?.();
};
const formatDetectorValue = (value, def) => `${Number(value) || 0}${def?.unit || ""}`;
const updateNumericDisplays = () => {
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
const threshold = Math.max(Number(count.min), Math.min(Number(count.max), Number(count.value) || 0));
count.value = String(threshold);
countLabel.textContent = formatDetectorValue(threshold, def);
count.setAttribute("aria-valuetext", countLabel.textContent);
widthLabel.textContent = `${Number(width.value) || 0}px`;
heightLabel.textContent = `${Number(height.value) || 0}px`;
width.setAttribute("aria-valuetext", widthLabel.textContent);
@ -926,32 +961,59 @@
};
let previousTarget = target.value;
const updateMode = (targetChanged = false) => {
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
count.min = String(def.min ?? 0);
count.max = String(def.max ?? 100);
count.step = String(def.step ?? 1);
if (targetChanged && previousTarget !== target.value) count.value = String(def.threshold);
const def = targetDef();
for (const control of [valueMin, valueMax, valueMinNumber, valueMaxNumber]) {
control.min = String(def.min ?? 0); control.max = String(def.max ?? 100); control.step = String(def.step ?? 1);
}
if (targetChanged && previousTarget !== target.value) {
valueMin.value = String(def.threshold);
valueMax.value = String(def.max);
}
previousTarget = target.value;
comparatorRow.hidden = Boolean(def.time);
countRow.hidden = Boolean(def.time);
valueRow.hidden = Boolean(def.time);
widthRow.hidden = !def.spatial;
heightRow.hidden = !def.spatial;
timeRow.hidden = !def.time;
updateNumericDisplays(); updateTimeRange(); updateOverlay();
updateValueRange(); updateNumericDisplays(); updateTimeRange(); updateOverlay();
};
count.oninput = updateNumericDisplays;
valueMin.oninput = () => updateValueRange("min");
valueMax.oninput = () => updateValueRange("max");
valueMinNumber.oninput = () => updateValueRange("min-number");
valueMaxNumber.oninput = () => updateValueRange("max-number");
width.oninput = () => { updateNumericDisplays(); updateOverlay(); };
height.oninput = () => { updateNumericDisplays(); updateOverlay(); };
timeStart.oninput = updateTimeRange; timeEnd.oninput = updateTimeRange;
const bindNearestThumbTeleport = (range, first, second, normalize, update) => {
if (!range || !first || !second) return;
range.onpointerdown = event => {
if (event.button !== 0 || event.target === first || event.target === second) return;
const rect = range.getBoundingClientRect();
if (!rect.width) return;
event.preventDefault();
const min = Number(first.min || 0), max = Number(first.max || 100);
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const raw = min + (max - min) * ratio;
const next = normalize(raw);
const firstValue = Number(first.value), secondValue = Number(second.value);
const targetThumb = Math.abs(next - firstValue) <= Math.abs(next - secondValue) ? first : second;
targetThumb.value = String(next);
update(targetThumb === first ? "min" : "max");
targetThumb.focus({ preventScroll: true });
};
};
bindNearestThumbTeleport(valueRange, valueMin, valueMax, value => normalizeValue(value, targetDef().threshold, targetDef()), source => updateValueRange(source));
bindNearestThumbTeleport(timeRange, timeStart, timeEnd, value => normalizeMinute(value, 0), updateTimeRange);
target.onchange = () => updateMode(true);
updateMode(false);
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
dialog.querySelector("#signalItemEditorApply").onclick = () => {
global.TarinaiHistory.capture(worldRef, "detector-edit");
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
item.pressureComparator = comparator.value === "lte" ? "lte" : "gte";
const targetDef = DETECTOR_TARGETS[item.pressureTarget] || DETECTOR_TARGETS.tarinai;
item.pressureThreshold = Math.max(Number(targetDef.min ?? 0), Math.min(Number(targetDef.max ?? 100), Number(count.value || 0)));
const def = targetDef();
item.pressureMin = normalizeValue(valueMin.value, def.threshold, def);
item.pressureMax = normalizeValue(valueMax.value, def.max, def);
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
item.pressureWidth = Math.max(60, Math.min(840, Number(width.value || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(height.value || 160) | 0));
item.pressureTimeStart = normalizeMinute(timeStart.value, 360);
@ -966,7 +1028,7 @@
}
function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch");
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch" || item.type === "circuit_board");
}
function findEditableItemAt(worldRef, x, y) {
@ -995,6 +1057,7 @@
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "fan") return openFanEditor(item, worldRef);
if (item.type === "pressure_switch") return openSignalItemEditor(item, worldRef);
if (item.type === "circuit_board") return global.TarinaiCircuitBoardSystem?.openEditor?.(item, worldRef) || false;
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
worldRef?.markSpatialDirty?.("gate-toggle");
@ -1474,6 +1537,9 @@
return;
}
}
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;

View file

@ -150,6 +150,13 @@
if (!it || it.dead || it.type === "splat") continue;
if ((this.tool === "rope" || this.tool === "rod" || this.tool === "spring") && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
if (wireTool && ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type)) continue;
if (wireTool && it.type === "circuit_board") {
const portHit = global.TarinaiCircuitBoardSystem?.nearestPort?.(it, x, y, 30);
if (!portHit || portHit.distance >= bestD) continue;
best = { kind: "item", id: it.id, type: it.type, label: `${toolLabel(it.type)} ${portHit.port.label}`, x: portHit.x, y: portHit.y, localX: portHit.localX, localY: portHit.localY, center: false, portId: portHit.port.portId };
bestD = portHit.distance;
continue;
}
const isMechanism = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(it.type));
const isFence = this.isFenceType?.(it.type) || false;
const isConstraint = ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type);

View file

@ -183,6 +183,9 @@
const previous = this.groundType || "soil";
this.groundType = next;
if (next !== previous || opts.force) {
if (next !== previous && !opts.silent) {
window.TarinaiAchievements?.recordGroundChange?.({ world: this, previous, next, silent: false });
}
window.TarinaiWeatherSystem.enforceFixedWeather(this);
this.markTerrainDirty?.("ground-type");
this.enforceGrassLimit?.("ground-type");

View file

@ -80,7 +80,7 @@ function createHarness(existingStorage = null, search = "", options = {}) {
setInterval() { timerId += 1; return timerId; },
clearInterval() {},
fetch: options.fetch || (async () => { throw new Error("offline"); }),
TARINAI_VERSION: "39.16.60",
TARINAI_VERSION: "39.16.65",
TarinaiGameDialogs: { confirm: async () => true },
};
context.window = context;
@ -109,12 +109,16 @@ const expectedIds = [
"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",
];
(async function run() {
let h = createHarness();
let storage;
assert(h.api.definitions.length === 54, "definition count must be 54");
assert(h.api.definitions.length === 64, "definition count must be 64");
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
const categorizedIds = h.api.categories.flatMap(category => category.ids);
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
@ -153,6 +157,15 @@ const expectedIds = [
assert(titles.sniper_333_shots === "狙撃手", "sniper title mismatch");
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility title mismatch");
assert(titles.low_fps_single_digit === "スペックがたりない", "low-fps title mismatch");
assert(titles.continuous_play_24_hours === "寝ろ", "24-hour title mismatch");
assert(titles.sandbox_five_toilets === "砂場", "sandbox title mismatch");
assert(titles.park_ground_changed === "そこ公共空間だよ?", "park-ground title mismatch");
assert(titles.ground_change_4_in_1_second === "破格の工事費用", "ground-spam title mismatch");
assert(titles.ant_nest_without_tarinai === "アリ観察", "ant observation title mismatch");
assert(titles.sticky_bomb_15_passes === "命のバトン", "sticky-bomb relay title mismatch");
assert(titles.daily_play_7_days === "毎日たりない観察", "daily observation title mismatch");
assert(titles.wire_shock_7_tarinai === "抵抗器じゃない", "wire shock title mismatch");
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king description mismatch");
@ -166,6 +179,15 @@ const expectedIds = [
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
assert(descriptions.idle_observer_5_minutes === "5分間画面を見るだけ", "observer description mismatch");
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
assert(descriptions.park_ground_changed === "公園の地面を勝手に変更する。", "park-ground description mismatch");
assert(descriptions.ground_change_4_in_1_second === "地面を変えすぎて破産する。", "ground-spam description mismatch");
assert(descriptions.sauna_cold_plunge === "暑すぎる状態になったたりないを、15秒以内に寒すぎる状態にする。", "sauna description mismatch");
assert(descriptions.ant_nest_without_tarinai === "アリの巣があるが、たりないがいない。", "ant observation description mismatch");
assert(descriptions.sticky_bomb_15_passes === "粘着ボムを爆発までに15回以上パスする。", "sticky-bomb relay description mismatch");
assert(!source.includes("TarinaiTooltips"), "achievement tooltips were not removed");
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
function walk(node, out = []) { for (const child of node?.children || []) { out.push(child); walk(child, out); } return out; }
@ -176,9 +198,11 @@ const expectedIds = [
const disclosedRevolutionCard = cards.find(node => node.dataset.achievementId === "revolution");
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
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]) {
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard]) {
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");
@ -188,6 +212,8 @@ const expectedIds = [
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
const categoryTitles = h.api.categories.map(category => category.title);
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
assert(JSON.stringify(operationIds.slice(-2)) === JSON.stringify(["continuous_play_1_hour", "true_tarinai_observer"]), "observation achievements are not at the bottom of その他");
h.api.unlock("natural_zunchi_slave");
const rerenderedGroups = h.elements.get("achievementList").children;
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
@ -403,6 +429,14 @@ const expectedIds = [
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
h = createHarness();
const lifespanSafeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
const lifespanSafeWorld = { time: 100, tarinai: lifespanSafeAlive, achievementNoDeathStartAt: 0 };
h.api.recordDeath({ world: lifespanSafeWorld, reason: "天寿を全うした", tarinai: { deathReason: "天寿を全うした" } });
assert(lifespanSafeWorld.achievementNoDeathStartAt === 0, "lifespan death reset safe-colony timer");
h.api.recordDeath({ world: lifespanSafeWorld, reason: "事故", tarinai: { deathReason: "事故" } });
assert(lifespanSafeWorld.achievementNoDeathStartAt === 100, "non-lifespan death did not reset safe-colony timer");
h = createHarness();
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
h.api.evaluateWorld(antWorld, { id: "relaxed" });
@ -418,6 +452,26 @@ const expectedIds = [
h.api.recordAntKilled({ world: {} });
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
h = createHarness();
const antObservationWorld = { tarinai: [], ants: [], items: [] };
h.api.evaluateWorld(antObservationWorld, {});
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked without an ant nest");
antObservationWorld.items.push({ type: "ant_nest", dead: false });
antObservationWorld.tarinai.push({ dead: false });
h.api.evaluateWorld(antObservationWorld, {});
assert(!unlocked(h, "ant_nest_without_tarinai"), "ant observation unlocked while a tarinai was alive");
antObservationWorld.tarinai[0].dead = true;
h.api.evaluateWorld(antObservationWorld, {});
assert(unlocked(h, "ant_nest_without_tarinai"), "ant observation did not unlock with a nest and zero living tarinai");
h = createHarness();
const stickyBomb = { type: "sticky_bomb", dead: false };
for (let i = 0; i < 14; i += 1) h.api.recordStickyBombPass(stickyBomb, {});
assert(!unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay unlocked before 15 passes");
h.api.recordStickyBombPass(stickyBomb, {});
assert(unlocked(h, "sticky_bomb_15_passes"), "sticky-bomb relay did not unlock at 15 passes");
h = createHarness();
h.api.recordSaveSlotsFilled({ filled: 8 });
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
@ -425,19 +479,50 @@ const expectedIds = [
assert(unlocked(h, "secret_collection_9_slots"), "save-slot collection did not unlock at 9");
h = createHarness();
[0, 250, 500].forEach(now => h.api.recordPauseClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked before four clicks");
h.api.recordPauseClick({ now: 999 });
assert(unlocked(h, "pause_spam_4_in_1_second"), "pause-spam did not unlock within one second");
[0, 250, 500].forEach(now => h.api.recordEmptyClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked before four clicks");
h.api.recordEmptyClick({ now: 999 });
assert(unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement did not unlock within one second");
h = createHarness();
[0, 400, 800, 1201].forEach(now => h.api.recordPauseClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked outside one-second window");
[0, 400, 800, 1201].forEach(now => h.api.recordEmptyClick({ now }));
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked outside one-second window");
h = createHarness();
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
h.api.recordContinuousPlay({ elapsedMs: 3600000 });
assert(unlocked(h, "continuous_play_1_hour"), "continuous play did not unlock at one hour");
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked at one hour");
h.api.recordContinuousPlay({ elapsedMs: 86399999 });
assert(!unlocked(h, "continuous_play_24_hours"), "24-hour play unlocked early");
h.api.recordContinuousPlay({ elapsedMs: 86400000 });
assert(unlocked(h, "continuous_play_24_hours"), "24-hour play did not unlock");
h = createHarness();
h.api.recordLowFps({ fps: 10 });
assert(!unlocked(h, "low_fps_single_digit"), "low-fps achievement unlocked at 10 fps");
h.api.recordLowFps({ fps: 9 });
assert(unlocked(h, "low_fps_single_digit"), "low-fps achievement did not unlock at 9 fps");
h = createHarness();
const sandboxWorld = { items: Array.from({ length: 4 }, () => ({ type: "toilet", dead: false })) };
h.api.evaluateSandbox(sandboxWorld);
assert(!unlocked(h, "sandbox_five_toilets"), "sandbox unlocked before five toilet sands");
sandboxWorld.items.push({ type: "toilet", dead: false });
h.api.evaluateSandbox(sandboxWorld);
assert(unlocked(h, "sandbox_five_toilets"), "sandbox did not unlock at five toilet sands");
h = createHarness();
h.api.recordGroundChange({ world: { fieldType: "garden" }, previous: "soil", next: "ice", now: 0 });
assert(!unlocked(h, "park_ground_changed"), "park-ground achievement unlocked outside park");
h.api.recordGroundChange({ world: { fieldType: "park" }, previous: "soil", next: "ice", now: 100 });
assert(unlocked(h, "park_ground_changed"), "park-ground achievement did not unlock in park");
h = createHarness();
const groundWorld = { fieldType: "garden" };
[0, 250, 500].forEach((now, index) => h.api.recordGroundChange({ world: groundWorld, previous: `g${index}`, next: `g${index + 1}`, now }));
assert(!unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement unlocked before four changes");
h.api.recordGroundChange({ world: groundWorld, previous: "g3", next: "g4", now: 999 });
assert(unlocked(h, "ground_change_4_in_1_second"), "ground-spam achievement did not unlock within one second");
h = createHarness();
const thermalWorld = {
@ -583,7 +668,7 @@ const expectedIds = [
h.api.evaluateCleanFreak(cleanWorld);
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
console.log("[OK] all 54 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
console.log("[OK] all 64 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
})().catch(error => {
console.error(error);
process.exitCode = 1;

View file

@ -1,15 +0,0 @@
const fs=require('fs'),vm=require('vm');
const path=require('path');
const root=path.join(__dirname,'..');
const c={console,Math,JSON,Object,Array,Map,Set,Date,Number,String,Boolean,Error,Uint8Array,
TarinaiAchievements:{isUnlocked(){return true;},exportSpellState(){return [3,0,0,0,[],Array(12).fill(0)];}},
TarinaiSeedFactory:{serialFromBirthSeed(){return 0;},ensureWorldSeed(world){world.worldSeed=world.worldSeed||'w0000000000000000';return world.worldSeed;}},
TarinaiSaveSchema:{SNAPSHOT_VERSION:35,FIELD_IDS:['garden'],GROUND_IDS:['soil'],WEATHER_IDS:['clear'],MOOD_IDS:['relaxed'],STATE_IDS:['idle'],POWER_MODE_IDS:['normal'],SIZE_MODE_IDS:['normal'],LIFE_MODE_IDS:['normal'],PIN_STATE_IDS:['none'],STAGE_IDS:['adult'],ITEM_TYPE_IDS:[],itemTypeIndex(){return -1;},canonicalMoodId(v){return v||'relaxed';},moodValue(v,f){return v||f;},enumIndex(list,v,f=0){const i=list.indexOf(v);return i>=0?i:f;},enumValue(list,i,f=''){return list[i]??f;}},
}; c.globalThis=c;c.window=c;vm.createContext(c);
vm.runInContext(fs.readFileSync(root+'/js/snapshot_system.js','utf8'),c);
const world={day:10,fieldType:'garden',tarinai:[],items:[],weather:'clear',time:100,weatherTimer:0,nextWeatherChange:10,deadCount:0,lastBirthAt:0,maxGeneration:1,colonyMood:{id:'relaxed'},groundType:'soil',achievementNaturalSlaveGenerations:[1,2,3,4,5],achievementNaturalKingGenerations:[1,2,3],achievementDirectFeedCount:33,achievementRobotCleanCount:200,achievementEnemyAntKills:10,achievementLastTarinaiDamageAt:99,achievementSelfSufficientStartAt:1,achievementLastDirectFeedAt:2,achievementLastInterventionAt:3,achievementNoDeathStartAt:4,achievementHappyStartAt:5};
const snap=c.TarinaiSnapshot.createSnapshot(world,{includeAchievements:true});
if(JSON.stringify(snap.g.w)!==JSON.stringify([[],[],0,0,0,null,-1,null,0,-1,-1])) throw new Error(JSON.stringify(snap.g.w));
if(snap.g.t.length||snap.g.i.length) throw new Error('entity metadata not empty');
if(!Array.isArray(snap.g.s)||snap.g.s[5].some(Boolean)) throw new Error('spell progress not zero');
console.log('[OK] completed world/entity/item achievement counters omitted from spell snapshot');

View file

@ -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) == 54, 'JS achievement count is not 54')
require(len(ids_js) == 64, 'JS achievement count is not 64')
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']), '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']), '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 = {
@ -67,12 +67,23 @@ checks = {
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
'safe colony timer': 'now - safeStart >= 300' in ach,
'safe colony ignores lifespan deaths': 'lifespanDeath' in ach and '\\u5bff\\u547d|\\u5929\\u5bff' in ach,
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
'idle observer timer': 'now - lastInterventionAt >= 300' in ach,
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" 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'),
'pause-spam hook': 'pauseButton' in ach and 'recordPauseClick' in ach and 'pauseBtn' in read('index.html'),
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach,
'empty-click hook': 'recordEmptyClick' in ach and 'recordEmptyClick?.' in read('js/world_placement_log.js') and 'recordPauseClick' not in ach and 'pauseButton?.addEventListener' not in ach,
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'CONTINUOUS_PLAY_24H_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach and 'continuousPlayStartedAt = Date.now()' in ach and 'if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now();' in ach,
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
'current-only achievement spell': 'const SPELL_STATE_VERSION = 5;' in ach and 'Number(payload[0]) !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js'),
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' in ach,
'absolute-zero item evaluation': 'ABSOLUTE_ZERO_C' in ach and 'recordBelowAbsoluteZeroItem(worldRef)' in ach and 'temperatureAt?.(item.x, item.y, null)' in ach,
'recovery sync is additive': "if (isset($state['unlocks'][$achievementId][$playerId])) continue;" in api,
'debug unlocks excluded from recovery': 'state.debugUnlocked?.includes?.(definition.id)' in ach,
@ -93,10 +104,15 @@ checks = {
'cleaner target raised to 200': 'ROBOT_CLEAN_TARGET = 200' in ach and r'\u7d2f\u8a08200\u500b' in ach,
'minimalist starts on day six': 'day >= 6 && !state.unlocked.minimalist_happy' in ach,
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
'compact server state v2': 'function compact_state' in api and "['v' => 2, 'p' => $players, 'u' => $unlocks]" in api,
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
'old generic fight progress is not migrated': 'progressSource.fightCount' not in ach and 'progressVersion === SPELL_STATE_VERSION' in ach,
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
'summary is read-only': "$mutatesPlayer = $action !== 'summary';" in api and 'if ($mutatesPlayer)' in api,
'completionist false cannot be re-added': "unset($syncUnlocks['true_tarinai_observer'])" in api,
'GET mutations are rejected': "$method === 'GET' && $action !== 'summary'" in api,
}
for name, ok in checks.items():
require(ok, name)

View file

@ -1,69 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const root = path.join(__dirname, "..");
const context = {
console,
Date,
Math,
JSON,
Object,
Array,
Map,
Set,
Uint8Array,
TextEncoder,
TextDecoder,
URLSearchParams,
performance,
location: { search: "" },
CompressionStream: undefined,
DecompressionStream: undefined,
TarinaiSnapshot: { version: 35 },
TarinaiSeedFactory: {
personalityArray() { return [100, 100, 100, 100]; },
birthProfile() { return { genetics: {} }; },
childSeed() { return "b1_0"; },
worldSeedFromWords(high, low) { return `w${(high >>> 0).toString(16).padStart(8, "0")}${(low >>> 0).toString(16).padStart(8, "0")}`; },
},
TarinaiSaveSchema: {
BINARY_SCHEMA_VERSION: 43,
FIELD_IDS: ["garden"],
itemTypeValue(_id, fallback = "") { return fallback; },
},
};
context.globalThis = context;
context.window = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, "js", "save_codec.js"), "utf8"), context, { filename: "save_codec.js" });
(async () => {
const metadata = {
w: [[1, 2], [3], 12, 87, 6, 145.5, 20, 18, 100, 90, -1],
t: [[10, 3, 120, 130]],
i: [[1, 121]],
s: [3, 3, 0, 28333333, [0, 2], [77, 13, 64, 2222, 19, 11, 12, 1025, 31, 444, 222, 555]],
};
const snapshot = {
v: 35,
a: "tj1",
m: [1, 0, "garden"],
w: [0, 0, 0, 120, 0, -9990, 1, 0, "seed", 0, 0, 0],
t: [[]],
i: [[0, 0, 0, 0, []]],
g: metadata,
};
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
if (JSON.stringify(decoded.g) !== JSON.stringify(metadata)) {
console.error("expected", JSON.stringify(metadata));
console.error("actual ", JSON.stringify(decoded.g));
throw new Error("achievement metadata changed during binary save roundtrip");
}
console.log(`[OK] sparse binary achievement metadata roundtrip passed (${encoded.length} spell chars)`);
})().catch(error => {
console.error(error);
process.exitCode = 1;
});

View file

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

View file

@ -1,160 +0,0 @@
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const root = path.join(__dirname, "..");
const source = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
class ClassList {
constructor() { this.values = new Set(["hidden"]); }
add(...names) { names.forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) { const next = force === undefined ? !this.values.has(name) : Boolean(force); if (next) this.values.add(name); else this.values.delete(name); return next; }
}
class Element {
constructor(id = "") { this.id = id; this.className = ""; this.classList = new ClassList(); this.dataset = {}; this.style = { setProperty() {}, removeProperty() {} }; this.children = []; this.textContent = ""; this.open = false; }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute() {}
addEventListener() {}
focus() {}
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
get offsetWidth() { return 100; }
}
function createHarness(initialState = null) {
const storage = new Map();
if (initialState) storage.set("tarinai_achievements_v2", JSON.stringify(initialState));
const elements = new Map();
[
"achievementsBtn", "achievementButtonCount", "achievementsDialog", "achievementDialogCount",
"achievementsCloseBtn", "achievementsCloseIconBtn", "achievementRefreshBtn", "achievementResetBtn", "achievementUnlockAllBtn",
"achievementList", "achievementSharedStatus", "achievementToast", "achievementToastTitle", "pauseBtn",
].forEach(id => elements.set(id, new Element(id)));
let timer = 0;
const context = {
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error,
AbortController, encodeURIComponent, URLSearchParams, Uint8Array,
location: { protocol: "file:", search: "" },
crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
removeItem(key) { storage.delete(key); },
},
document: { hidden: false, getElementById(id) { return elements.get(id) || null; }, createElement() { return new Element(); }, addEventListener() {} },
TarinaiEvents: { emit() {} }, showToast() {}, audio: { uiClick() {} },
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {},
fetch: async () => { throw new Error("offline"); },
TARINAI_VERSION: "39.16.60", TarinaiGameDialogs: { confirm: async () => true },
};
context.window = context;
context.globalThis = context;
vm.createContext(context);
vm.runInContext(source, context, { filename: "achievements.js" });
return { api: context.TarinaiAchievements, storage };
}
function assert(condition, message) { if (!condition) throw new Error(message); }
function storedState(harness) { return JSON.parse(harness.storage.get("tarinai_achievements_v2")); }
const state = {
unlocked: {
first_birth: 1700000000000,
natural_zunchi_slave: 1700000123000,
lifespan_completed: 1700000999000,
},
pending: [], resetPending: false, debugUnlocked: [],
progress: {
placementCount: 77,
linkTypes: ["rope", "spring"],
signalActivated: true,
antKills: 64,
birthCount: 2222,
quickDeleteCount: 19,
undoCount: 11,
redoCount: 12,
medicineTypes: ["first_aid", "protein", "dwarf_drug"],
firstAidHeals: 31,
fightMochiFightCount: 444,
shotCount: 222,
loveMochiBirthCount: 555,
},
};
const sourceHarness = createHarness(state);
assert(sourceHarness.api.definitions.length === 54, "spell audit expected 54 achievements");
const payload = sourceHarness.api.exportSpellState();
const compactLength = JSON.stringify(payload).length;
assert(Array.isArray(payload) && payload[0] === 3, "spell achievement payload version mismatch");
assert(payload[5].length === 12, "spell achievement progress field count mismatch");
assert(compactLength < 230, `spell achievement payload is not compact enough: ${compactLength} chars`);
const allUnlocked = Object.fromEntries(sourceHarness.api.definitions.map((definition, index) => [definition.id, 1700000000000 + index * 60000]));
const fullState = { ...state, unlocked: allUnlocked };
const fullPayload = createHarness(fullState).api.exportSpellState();
const fullPayloadLength = JSON.stringify(fullPayload).length;
assert(fullPayloadLength < 500, `full achievement payload is not compact enough: ${fullPayloadLength} chars`);
assert(fullPayload[5].every(value => value === 0), "completed achievement counters were stored in the spell payload");
const restored = createHarness();
const result = restored.api.importSpellState(payload);
assert(result.included && result.unlockedAdded === 3, "spell import did not restore unlocked achievements");
for (const id of ["first_birth", "natural_zunchi_slave", "lifespan_completed"]) assert(restored.api.isUnlocked(id), `${id} was not restored`);
const restoredState = storedState(restored);
assert(restoredState.progress.placementCount === 77, "placement progress was not restored");
assert(restoredState.progress.birthCount === 2222, "birth progress was not restored");
assert(restoredState.progress.fightMochiFightCount === 444, "fight-mochi progress was not restored");
assert(restoredState.progress.shotCount === 222, "shot progress was not restored");
assert(restoredState.progress.loveMochiBirthCount === 555, "love-mochi birth progress was not restored");
assert(restoredState.progress.signalActivated === true, "signal progress was not restored");
assert(restoredState.progress.linkTypes.includes("rope") && restoredState.progress.linkTypes.includes("spring"), "link progress was not restored");
assert(restoredState.progress.medicineTypes.includes("first_aid") && restoredState.progress.medicineTypes.includes("dwarf_drug"), "medicine progress was not restored");
const localState = {
unlocked: { soccer_ball_death: 1600000000000 }, pending: [], resetPending: false, debugUnlocked: [],
progress: {
placementCount: 90, linkTypes: ["rod"], signalActivated: false, antKills: 80, birthCount: 2500,
quickDeleteCount: 20, undoCount: 15, redoCount: 14, medicineTypes: ["mercury"], firstAidHeals: 40,
fightMochiFightCount: 500, shotCount: 300, loveMochiBirthCount: 600,
},
};
const merged = createHarness(localState);
merged.api.importSpellState(payload);
const mergedState = storedState(merged);
assert(merged.api.isUnlocked("soccer_ball_death") && merged.api.isUnlocked("first_birth"), "spell import erased or failed to merge achievements");
assert(mergedState.progress.placementCount === 90, "lower imported placement progress overwrote local progress");
assert(mergedState.progress.birthCount === 2500, "lower imported birth progress overwrote local progress");
assert(mergedState.progress.fightMochiFightCount === 500, "lower imported fight-mochi progress overwrote local progress");
assert(mergedState.progress.shotCount === 300, "lower imported shot progress overwrote local progress");
assert(mergedState.progress.loveMochiBirthCount === 600, "lower imported fertility progress overwrote local progress");
assert(mergedState.progress.linkTypes.length === 3, "link progress was not union-merged");
assert(mergedState.progress.medicineTypes.includes("mercury") && mergedState.progress.medicineTypes.includes("protein"), "medicine progress was not union-merged");
const legacyMask = "01000000000000";
const legacyRestored = createHarness();
const legacyResult = legacyRestored.api.importSpellState([1, legacyMask, 1700000000, [0], [1, 0, 0, 0, 0, 0, 0, 0, 0, 999]]);
assert(legacyResult.included && legacyRestored.api.isUnlocked("first_birth"), "legacy v1 achievement spell was not imported");
assert(storedState(legacyRestored).progress.fightMochiFightCount === 0, "legacy generic fight progress polluted the new fight-mochi counter");
const previousRestored = createHarness();
previousRestored.api.importSpellState([2, 1, 0, 28333333, [0], [1, 0, 0, 0, 0, 0, 0, 0, 0, 666]]);
assert(storedState(previousRestored).progress.fightMochiFightCount === 0, "v2 generic fight progress polluted the new fight-mochi counter");
let rejected = false;
try { restored.api.importSpellState([3, -1, 0, 0, [], []]); } catch (_) { rejected = true; }
assert(rejected, "malformed achievement spell payload was accepted");
const saveSystemSource = fs.readFileSync(path.join(root, "js", "save_system.js"), "utf8");
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
assert(saveSystemSource.includes("createSnapshot(global.world, { includeAchievements: true })"), "spell export does not request achievement state");
assert(!/async function saveSlot[\s\S]*?includeAchievements:\s*true/.test(saveSystemSource.match(/async function saveSlot[\s\S]*?async function loadSlot/)?.[0] || ""), "slot saves unexpectedly include global achievement state");
assert(snapshotSource.includes("metadata.s = spellState"), "snapshot metadata does not carry compact achievement state");
assert(snapshotSource.includes("importSpellState?.(snapshot.g.s)"), "spell load does not import achievement state");
assert(snapshotSource.includes('needRobotClean = !isUnlocked("robot_cleaner_100")'), "completed field counters are not omitted from spell metadata");
assert(snapshotSource.includes('needOverprotective = !isUnlocked("overprotective")'), "completed per-tarinai counters are not omitted from spell metadata");
assert(snapshotSource.includes('needQuickDelete = !isUnlocked("unplanned_city_30")'), "completed per-item counters are not omitted from spell metadata");
console.log(`[OK] v3 compact achievement spell payload (${compactLength} chars sample / ${fullPayloadLength} chars full), completed-counter omission, merge restore, old-format isolation, malformed-data rejection, and slot isolation passed`);

View file

@ -1,55 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const root = path.join(__dirname, "..");
const context = {
console, Date, Math, JSON, Object, Array, Map, Set, Uint8Array,
TextEncoder, TextDecoder, URLSearchParams, performance,
CompressionStream, DecompressionStream,
location: { search: "" },
TarinaiSnapshot: { version: 35 },
TarinaiSaveSchema: {
BINARY_SCHEMA_VERSION: 43,
FIELD_IDS: ["garden"],
itemTypeValue(_id, fallback = "") { return fallback; },
},
};
context.globalThis = context;
context.window = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, "js", "save_codec.js"), "utf8"), context, { filename: "save_codec.js" });
const legacySpell = "た㣏洰祶蘬䕘㘎㗱餞皤庒袿랿涾뎳夃靅㸊倄遲诡㶣莡䵡錻缀袔鐨묪辶鎽묽砻菏뼿椳欛琛뺯琘뀛矎䗁蓐筓俇뗇㛂熋汯伂磌鉡䞳搶逷礣됽뷻鏲㨝尻㷿㭫牫㬈焂铤䅳㸢粃腓誘岜圉缴聦秮杰醆㳇幖豣羬懄䫌릘㾎惛橉芊芰㙞랷邆묒罸䅰哿鷔鉐皣栠䝹燂䒹蟜㲈婑裈㲵嫡䋪㲛갭效处彤䒴庭䴐姜脈礩弔粋肫䬑熶嬔翈哥渖㤈㱶闔䒚抉弐㡝酵义秮肘醃䆿傏铪彂櫾佌뭰叺馞稘焤봾岲䆫㐝";
const progress = [77, 13, 64, 2222, 19, 11, 12, 1025, 31, 444, 222, 555];
const metadata = {
w: [[1, 2, 3, 4, 5], [3, 4, 5], 12, 187, 6, 145.5, 20, 18, 100, 90, -1],
t: [],
i: [],
s: [3, 0xffffffff, (2 ** 22) - 1, Math.floor(1700000000 / 60), Array.from({ length: 54 }, (_value, index) => Math.floor(index * 12345 / 60)), progress],
};
const snapshot = {
v: 35,
a: "tj1",
m: [1, 0, "garden"],
w: [0, 0, 0, 120, 0, -9990, 1, 0, "seed", 0, 0, 0],
t: [],
i: [],
g: metadata,
};
(async () => {
const legacyDecoded = await context.TarinaiSaveCodec.decodeSnapshot(legacySpell);
if (legacyDecoded?.g?.s?.[0] !== 1) throw new Error("legacy schema-41 spell was not decoded");
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
if (decoded?.g?.s?.[0] !== 3) throw new Error("binary achievement spell did not roundtrip");
const saved = legacySpell.length - encoded.length;
const ratio = saved / legacySpell.length;
if (saved <= 0 || ratio < 0.35) throw new Error(`achievement spell compression regression: ${legacySpell.length} -> ${encoded.length}`);
console.log(`[OK] achievement spell binary compression ${legacySpell.length} -> ${encoded.length} chars (${(ratio * 100).toFixed(1)}% reduction), legacy decode passed`);
})().catch(error => {
console.error(error);
process.exitCode = 1;
});

View file

@ -25,7 +25,7 @@ function harness(storage = new Map()) {
location:{protocol:"file:",search:""},crypto:{randomUUID:()=>"11111111-1111-4111-8111-111111111111"},
localStorage:{getItem:k=>storage.has(k)?storage.get(k):null,setItem:(k,v)=>storage.set(k,String(v)),removeItem:k=>storage.delete(k)},
document:{hidden:false,getElementById:id=>elements.get(id)||null,createElement:()=>new Element(),addEventListener(){}},
TarinaiEvents:{emit(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.60"};
TarinaiEvents:{emit(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.65"};
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
return {c,api:c.TarinaiAchievements,storage,elements};
}
@ -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===54,"must have 54 achievements");
assert(h.api.definitions.length===64,"must have 64 achievements");
h.api.open();
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
@ -149,18 +149,24 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
h=harness();const noOil={};h.api.recordFightStarted({world:{time:0},a:noOil,b:{}});assert(off(h,"fuel_to_fire"),"fight without mochi unlocked at time zero");
h=harness();const oil2={};const fw2={time:0};h.api.recordConsumableUse({world:fw2,tarinai:oil2,type:"fight_mochi"});fw2.time=30.01;h.api.recordFightStarted({world:fw2,a:oil2,b:{}});assert(off(h,"fuel_to_fire"),"fight mochi over 30s");
// Enemy's enemy: 30 alive, no tarinai damage for 10 sec, danger source, field-local count.
h=harness(); const ew={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};
// Enemy's enemy: 30 alive, danger-item kills, and any dangerous tarinai hit resets the streak.
h=harness(); const ew={time:20,tarinai:alive(30)};
for(let i=0;i<9;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");const ant10={world:ew};h.api.recordAntDamageSource(ant10,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant:ant10});assert(on(h,"enemy_enemy_friend"),"enemy achievement at 10");
h=harness();const ew2={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:11};for(let i=0;i<10;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}assert(off(h,"enemy_enemy_friend"),"enemy counted within 10s of tarinai damage");
ew2.time=22;ew2.achievementLastTarinaiDamageAt=11;const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert(off(h,"enemy_enemy_friend"),"non-danger ant kill counted");
const noCollateral={time:50,tarinai:alive(30),achievementLastTarinaiDamageAt:-Infinity};
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"food"},{world:noCollateral});
assert(noCollateral.achievementLastTarinaiDamageAt===-Infinity,"ordinary damage incorrectly blocked enemy achievement");
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"firecracker"},{world:noCollateral});
assert(noCollateral.achievementLastTarinaiDamageAt===50,"danger collateral was not recorded");
h=harness();const ew3={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");
h.api.recordDamageSource(ew.tarinai[0],{type:"firecracker"},{world:ew});
assert((ew.achievementEnemyAntKills||0)===0,"dangerous tarinai damage did not reset enemy streak");
for(let i=0;i<10;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
assert(on(h,"enemy_enemy_friend"),"enemy achievement did not unlock after 10 clean danger kills");
h=harness();const ew2={time:20,tarinai:alive(30)};
for(let i=0;i<5;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}
ew2.tarinai[0].dead=true;const resetAnt={world:ew2};h.api.recordAntDamageSource(resetAnt,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:resetAnt});
assert((ew2.achievementEnemyAntKills||0)===0,"population below 30 did not reset enemy streak");
const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert((ew2.achievementEnemyAntKills||0)===0,"non-danger ant kill counted");
h=harness();const ew3={time:20,tarinai:alive(30)};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
// New observation and sticky-bomb relay achievements.
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
// Static integration guards for all production hook points.
const checks={
@ -180,6 +186,9 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
"js/tarinai_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
"js/tarinai_disease_nest.js":["isSheltered?.(this.world, this.x, this.y, this)"],
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
"js/snapshot_system.js":["stickyBombPassCount"],
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
};
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");

View file

@ -2106,7 +2106,7 @@ def check_connection_overlay_and_time_detector() -> None:
fail("detector dual-thumb slider styling is missing")
if 'const targets = ["tarinai", "item", "time", "hunger_avg"' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
fail("expanded detector snapshot persistence is missing")
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureComparator' not in placement:
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureMin' not in placement or 'data.pressureMax' not in placement:
fail("expanded detector copy persistence is missing")
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
fail("non-spatial detectors still render a detection rectangle")
@ -2587,8 +2587,8 @@ def check_parasol_rain_and_preset_updates() -> None:
required = [
(placement, 'overlay: overlayForItem(tmp)', "parasol placement preview overlay is not exported"),
(placement, 'shape: "ellipse", centerX: footprint.centerX, centerY: footprint.centerY', "parasol preview does not reuse shade geometry"),
(render, 'ctx.ellipse(overlay.centerX, overlay.centerY', "parasol placement overlay is not drawn at shade center"),
(placement, 'shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY', "parasol preview does not reuse shade geometry"),
(render, 'overlay?.shape === "roundedRect"', "shape-matched placement overlays are not rendered"),
(weather, 'drop.rainDrop = opts?.rainDrop === true || (!hasX && !hasY);', "rain-created water is not tagged"),
(ambient, 'const sand = worldRef.toiletSandAt?.(drop.x, drop.y) || null;', "toilet sand does not intercept rain water"),
(ambient, '"toilet-sand-rain-absorb"', "rain absorption does not invalidate toilet sand rendering"),
@ -2630,7 +2630,7 @@ vm.runInContext({placement!r}, overlayContext, {{ filename: 'placement_preview_s
const previewWorld = {{ tool: 'rain_shelter', pointer: {{ inside: true, x: 100, y: 120 }}, toolSizeScale() {{ return 1; }}, toolSizeFor() {{ return 'medium'; }}, placementBlocked() {{ return false; }}, w: 800, h: 600 }};
const preview = overlayContext.TarinaiPlacementPreviewSystem.forWorld(previewWorld);
const shade = overlayContext.TarinaiParasolSystem.parasolFootprint(preview.item);
if (!preview.overlay || preview.overlay.centerX !== shade.centerX || preview.overlay.centerY !== shade.centerY || preview.overlay.radiusX !== shade.radiusX || preview.overlay.radiusY !== shade.radiusY) throw new Error('parasol preview overlay and shade diverged');
if (!preview.overlay || preview.overlay.centerX !== shade.centerX || preview.overlay.centerY !== shade.centerY || preview.overlay.halfWidth !== shade.radiusX || preview.overlay.halfHeight !== shade.radiusY || preview.overlay.shape !== 'roundedRect') throw new Error('parasol preview overlay and shade diverged');
const ambientContext = {{ console, globalThis: null, window: null, Math, Object }};
ambientContext.globalThis = ambientContext; ambientContext.window = ambientContext;
@ -2817,7 +2817,7 @@ def check_hair_trigger_tooltips_and_physics_icons() -> None:
contains_local(layout, '"hair_trigger"', "field dialog preset fallback omits hair-trigger preset")
contains_local(index, 'data-reset-preset="hair_trigger"', "hair-trigger preset button missing")
contains_local(tools, 'function drawPhysicsToolIcon(canvas, toolId)', "dedicated physics icon renderer missing")
contains_local(tools, 'category.id === "physics" && attachPhysicsToolIcon(btn, toolId)', "physics category does not use dedicated icons")
contains_local(tools, 'const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId);', "dedicated item silhouettes are not attached across categories")
for tool_id in ["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]:
contains_local(tools, f'toolId === "{tool_id}"', f"distinct physics icon branch missing: {tool_id}")

View file

@ -1,19 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const achievement = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
const save = fs.readFileSync(path.join(root, "js", "save_system.js"), "utf8");
const dialog = fs.readFileSync(path.join(root, "js", "game_dialogs.js"), "utf8");
const index = fs.readFileSync(path.join(root, "index.html"), "utf8");
function assert(value, message) { if (!value) throw new Error(message); }
assert(!/\b(?:window\.|global\.)?(?:alert|confirm|prompt)\s*\(/.test(achievement + save), "native browser popup call remains");
assert(achievement.includes("TarinaiGameDialogs") && save.includes("TarinaiGameDialogs"), "in-game confirmation API is not used");
assert(dialog.includes('role", "alertdialog"') && dialog.includes("data-game-dialog-confirm"), "shared in-game confirmation dialog is incomplete");
assert(!achievement.includes("TarinaiTooltips") && !achievement.includes("dataset.tip"), "achievement tooltip binding remains");
assert(achievement.includes("ACHIEVEMENT_CATEGORIES") && achievement.includes("createAchievementGroup"), "achievement category rendering is missing");
assert(!achievement.includes('id: "completed"'), "separate completed group still exists");
assert(achievement.includes("orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]"), "unlocked achievements are not sorted to the top inside categories");
assert(["\\u751f\\u614b", "\\u5b9f\\u9a13", "\\u5efa\\u7bc9", "\\u305d\\u306e\\u4ed6"].every(label => achievement.includes(`title: "${label}"`)), "category titles are incorrect");
assert(index.includes('placeholder="&#x691C;&#x7D22;"'), "search placeholder was not shortened");
console.log("[OK] achievement grouping, inline disclosures, and in-game confirmation dialogs passed");

View file

@ -1,64 +0,0 @@
"use strict";
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const root = path.join(__dirname, "..");
function assert(v, m) { if (!v) throw new Error(m); }
// Verify the manual push direction for all three passive mechanical types.
const context = {
console, Math, Number, Object, Array, Set, Map, WeakMap, Date,
clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); },
TarinaiCollisionFootprints: { hitTestItem() { return { hit: false, distance: Infinity }; } },
TarinaiPhysicsBodySystem: {
ensureBody(item) { return item.physicsBody; },
scalar(item, key, fallback = 0) {
const b = item.physicsBody || {};
if (key === "spin") return b.velocity?.angular ?? fallback;
if (key === "slideSpeed") return b.velocity?.linear ?? fallback;
if (key === "xv") return b.velocity?.x ?? fallback;
if (key === "yv") return b.velocity?.y ?? fallback;
if (key === "motorOn") return b.motor?.powered ?? fallback;
if (key === "railOn") return b.motor?.powered ?? fallback;
return b.state?.[key] ?? fallback;
},
setScalar(item, key, value) {
const b = item.physicsBody;
b.velocity ||= { x: 0, y: 0, angular: 0, linear: 0 };
b.state ||= {};
if (key === "spin") b.velocity.angular = value;
else if (key === "slideSpeed") b.velocity.linear = value;
else if (key === "xv") b.velocity.x = value;
else if (key === "yv") b.velocity.y = value;
else b.state[key] = value;
return true;
},
applyBodyState() { return true; },
},
TarinaiSignalSystem: { powerOverride() { return null; } },
};
context.window = context; context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, "js", "mechanical_system.js"), "utf8"), context, { filename: "mechanical_system.js" });
const body = (type) => ({
type, x: 0, y: 0, angle: 0, r: 40, world: { time: 0, markSpatialDirty() {} },
physicsBody: { type, pose: { x: 0, y: 0, angle: 0 }, velocity: { x: 0, y: 0, angular: 0, linear: 0 }, motor: { powered: false }, rail: { axisAngle: 0 }, state: { mass: 1, inertia: 3200 } },
});
let item = body("reciprocator");
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
assert(item.physicsBody.velocity.linear < 0, "reciprocator moved toward the poke point");
item = body("poison_block");
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
assert(item.physicsBody.velocity.x < 0, "poison block moved toward the poke point");
item = body("rotator");
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 0, -30, item.world);
assert(item.physicsBody.velocity.angular > 0, "rotator poke torque is reversed");
const placement = fs.readFileSync(path.join(root, "js", "world_placement_log.js"), "utf8");
const input = fs.readFileSync(path.join(root, "js", "ui_input_shared.js"), "utf8");
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
assert(placement.includes("findPokeTargetAt"), "shared poke target finder is missing");
assert(placement.indexOf('item.type !== "plushie"') < placement.indexOf('item.type !== "ball"'), "plushie is not prioritized by poke targeting");
assert(placement.includes("recordPlushiePokeFling"), "plushie achievement hook is missing");
assert(input.includes("hoverPokeTarget") && render.includes('label: "\\u3064\\u3064\\u304f"'), "poke overlay is missing");
console.log("[OK] poke targeting, overlay, plushie fling, and passive mechanism push directions passed");

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "39.16.60";
const APP_VERSION = "39.16.74";
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", "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", "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_log", "ui_helpers", "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_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"
];
const CORE_ASSETS = [
"./",