diff --git a/.achievement_data/.gitkeep b/.achievement_data/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/.achievement_data/.htaccess b/.achievement_data/.htaccess
deleted file mode 100644
index fa39801..0000000
--- a/.achievement_data/.htaccess
+++ /dev/null
@@ -1,6 +0,0 @@
-
- Require all denied
-
-
- Deny from all
-
diff --git a/AI_MAP.md b/AI_MAP.md
index c1759d3..bab0e6e 100644
--- a/AI_MAP.md
+++ b/AI_MAP.md
@@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
-- Version/cache: `app_manifest.json`, `js/version.js`, 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.
diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt
index b87f01e..3bc1dba 100644
--- a/BUILD_NOTE.txt
+++ b/BUILD_NOTE.txt
@@ -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 41–43.
+
+39.16.63: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4.
+
+## 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.
diff --git a/FILES.json b/FILES.json
index cf94b15..897b5a4 100644
--- a/FILES.json
+++ b/FILES.json
@@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
- "version": "39.16.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."
},
diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md
index 559f968..d0bf495 100644
--- a/GAME_FEATURES.md
+++ b/GAME_FEATURES.md
@@ -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 0–100%, and the global achiever count is displayed beside it.
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
@@ -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.
diff --git a/README.md b/README.md
index 607a6ae..b31a919 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
-- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.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 41–43.
+
+- v39.16.63: Fixed achievement progress text weight and continuous-play tab switching; added アリ観察 and 命のバトン; corrected サウナ水風呂 and 敵の敵は味方 condition mismatches; save/spell data accepts only schema 45 and achievement payload v4.
+- v39.16.64: Added 毎日たりない観察 and 抵抗器じゃない; introduced calendar-day streak persistence; and migrated server aggregate JSON to compact v3 positional data automatically on read.
+- v39.16.65: Hardened pre-deployment server migration with read-only summaries, POST-only mutations, corruption-safe decoding, atomic writes under a separate lock, completionist eligibility filtering, and seeded randomized/concurrent migration audits.
+
+- v39.16.66: Added two-user seesaw play, an editable AND/OR/NOT circuit board with positioned terminals, signal control for fan/stove/dry ice/duplicator, and wire-target highlighting.
+
+- v39.16.67: Replaced the circuit-board dropdown editor with direct grid placement and cursor-drawn wiring; changed colony-state evaluation to every two in-game hours and excluded growing populations from Weakened; fixed seesaw action selection, fixed its orientation, and matched the placement overlay to its shape.
+- v39.16.69: Reworked circuit-board wiring into straight grid segments with branches and multi-connect terminals; removed OR and added a rectifier; made internal parts rotatable and external terminal count/positions editable; expanded board terminal spacing; changed detector numeric conditions to min/max dual sliders with number inputs; and raised seesaw riders slightly above the seats.
+- v39.16.68: Fixed seesaw participation so idle/bored Tarinai can initiate play and a waiting rider strongly recruits a partner; selected data reports active play as 「シーソーをしている。」; moved seesaws behind sprite layers; and aligned all two-input circuit gate pins to the 40px wiring grid.
+- v39.16.70: Reduced the default circuit-board footprint to one quarter of the previous area; made grid width/height editable; made external terminals movable on all four edges with a height-dependent count limit; made branch junctions movable; reduced NOT/rectifier body sizes; and corrected seesaw action-reason text.
+
+- v39.16.71: Replaced circuit-board terminal +/- controls with direct IN/OUT terminal placement and erasing, dimmed terminals without external world wires, removed generic elliptical placement previews, added an Electrical tool category for detector/wires/circuit board, and made detector range-track clicks move the nearest thumb.
+
+- v39.16.72: Circuit boards now default to 10x10 with a 20x20 maximum, AND uses a two-cell height, compact gates can sit closer to top/bottom edges, placement previews follow occupancy footprints more directly, and unstable internal logic loops are explicitly forced OFF.
+- v39.16.73: Circuit-board editing now shows placement ghosts for internal parts and terminals, Q/E rotates the pending internal part by 90 degrees, ON constant sources are explained in the editor, and wire/insulated-wire palette silhouettes render correctly after the Electrical category move.
+- v39.16.74: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire.
diff --git a/achievement_api.php b/achievement_api.php
index d6dc995..32f9d88 100644
--- a/achievement_api.php
+++ b/achievement_api.php
@@ -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);
diff --git a/app_manifest.json b/app_manifest.json
index 02cf606..c4b33a6 100644
--- a/app_manifest.json
+++ b/app_manifest.json
@@ -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",
diff --git a/css/base.css b/css/base.css
index cb70d5f..91b9d49 100644
--- a/css/base.css
+++ b/css/base.css
@@ -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)}
diff --git a/css/components.css b/css/components.css
index 88b122b..f7c70ef 100644
--- a/css/components.css
+++ b/css/components.css
@@ -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 {
diff --git a/index.html b/index.html
index 2cab199..53b01dc 100644
--- a/index.html
+++ b/index.html
@@ -4,14 +4,14 @@
たりない観察
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -42,7 +42,7 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+